no message
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<view v-if="!appStore.configLoaded" class="app-loading-mask">
|
||||
<view class="app-loading-spinner">
|
||||
<view class="app-loading-circle"></view>
|
||||
</view>
|
||||
<text class="app-loading-text">{{ text }}</text>
|
||||
|
||||
<view v-if="timeout" class="app-loading-tip">
|
||||
<text class="app-loading-tip__title">⚠️ 加载缓慢</text>
|
||||
<text class="app-loading-tip__desc">请检查网络后关闭并重新打开 App</text>
|
||||
<view class="app-loading-tip__btn" @tap="retry">
|
||||
<text>重试</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '加载中...'
|
||||
}
|
||||
})
|
||||
|
||||
const appStore = useAppStore()
|
||||
const timeout = ref(false)
|
||||
let timer: any = null
|
||||
|
||||
const startTimer = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
if (!appStore.configLoaded) {
|
||||
timeout.value = true
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const retry = () => {
|
||||
timeout.value = false
|
||||
startTimer()
|
||||
appStore.getConfig().catch((err: any) => {
|
||||
console.error('[AppLoading] 重试加载配置失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!appStore.configLoaded) startTimer()
|
||||
})
|
||||
|
||||
watch(() => appStore.configLoaded, (val) => {
|
||||
if (val) {
|
||||
timeout.value = false
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
} else {
|
||||
startTimer()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-loading-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.app-loading-spinner {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.app-loading-circle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 6rpx solid #f0f0f0;
|
||||
border-top-color: #0b55eb;
|
||||
border-radius: 50%;
|
||||
animation: app-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes app-loading-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.app-loading-text {
|
||||
font-size: 26rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.app-loading-tip {
|
||||
margin-top: 64rpx;
|
||||
padding: 32rpx 40rpx;
|
||||
background: #fff8e6;
|
||||
border: 2rpx solid #ffd591;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 560rpx;
|
||||
|
||||
&__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #d46b08;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
color: #ad6800;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
padding: 14rpx 48rpx;
|
||||
background: #0b55eb;
|
||||
border-radius: 40rpx;
|
||||
|
||||
text {
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<button
|
||||
class="avatar-upload p-0 m-0 rounded"
|
||||
:style="styles"
|
||||
hover-class="none"
|
||||
open-type="chooseAvatar"
|
||||
@click="chooseAvatar"
|
||||
@chooseavatar="chooseAvatar"
|
||||
>
|
||||
<image class="w-full h-full" mode="heightFix" :src="modelValue" v-if="modelValue" />
|
||||
<slot v-else>
|
||||
<div
|
||||
:style="styles"
|
||||
class="border border-dotted border-light flex w-full h-full flex-col items-center justify-center text-muted text-xs box-border rounded"
|
||||
>
|
||||
<u-icon name="plus" :size="36" />
|
||||
添加图片
|
||||
</div>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { uploadImage } from '@/api/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { addUnit } from '@/utils/util'
|
||||
import { isBoolean } from 'lodash'
|
||||
import { computed, CSSProperties, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String
|
||||
},
|
||||
fileKey: {
|
||||
type: String,
|
||||
default: 'uri'
|
||||
},
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: 120
|
||||
},
|
||||
round: {
|
||||
type: [Boolean, String, Number],
|
||||
default: false
|
||||
},
|
||||
border: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
const userStore = useUserStore()
|
||||
const styles = computed<CSSProperties>(() => {
|
||||
const size = addUnit(props.size)
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: isBoolean(props.round) ? (props.round ? '50%' : '') : addUnit(props.round)
|
||||
}
|
||||
})
|
||||
|
||||
const chooseAvatar = (e: any) => {
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.navigateTo({
|
||||
url: '/uni_modules/vk-uview-ui/components/u-avatar-cropper/u-avatar-cropper?destWidth=300&rectWidth=200&fileType=jpg'
|
||||
})
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
const path = e.detail?.avatarUrl
|
||||
if (path) {
|
||||
uploadImageIng(path)
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
const uploadImageIng = async (file: string) => {
|
||||
uni.showLoading({
|
||||
title: '正在上传中...'
|
||||
})
|
||||
try {
|
||||
const res: any = await uploadImage(file, userStore.temToken!)
|
||||
uni.hideLoading()
|
||||
console.log(res)
|
||||
emit('update:modelValue', res[props.fileKey])
|
||||
} catch (error) {
|
||||
uni.hideLoading()
|
||||
uni.$u.toast(error)
|
||||
}
|
||||
}
|
||||
// 监听从裁剪页发布的事件,获得裁剪结果
|
||||
uni.$on('uAvatarCropper', (path) => {
|
||||
uploadImageIng(path)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
uni.$off('uAvatarCropper')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatar-upload {
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<u-popup v-model="visible" mode="top" length="100%" :mask="true" :mask-close-able="true" @close="handleClose">
|
||||
<view class="channel-manage" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="channel-manage__header">
|
||||
<view class="channel-manage__close" @tap="handleClose">
|
||||
<u-icon name="close" size="36" />
|
||||
</view>
|
||||
<view class="channel-manage__header-right">
|
||||
<view class="channel-manage__search" @tap="goSearch">
|
||||
<u-icon name="search" size="36" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="channel-manage__scroll">
|
||||
<!-- 我的频道 -->
|
||||
<view class="channel-section">
|
||||
<view class="channel-section__header">
|
||||
<text class="channel-section__title">我的频道</text>
|
||||
<text class="channel-section__edit" @tap="toggleEdit">{{ isEditing ? '完成' : '编辑' }}</text>
|
||||
</view>
|
||||
<view class="channel-grid">
|
||||
<view v-for="(item, index) in myChannels" :key="item.id" class="channel-tag" :class="{
|
||||
'channel-tag--active': index === activeIndex,
|
||||
'channel-tag--fixed': item.fixed
|
||||
}" @tap="handleMyChannelClick(index)">
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="isEditing && !item.fixed" class="channel-tag__remove">-</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 推荐频道 -->
|
||||
<view v-for="group in recommendGroups" :key="group.title" class="channel-section">
|
||||
<view class="channel-section__header">
|
||||
<text class="channel-section__title">{{ group.title }}</text>
|
||||
</view>
|
||||
<view class="channel-grid">
|
||||
<view v-for="item in group.channels" :key="item.id" class="channel-tag channel-tag--add"
|
||||
@tap="isEditing && handleAddChannel(item)">
|
||||
<text>{{ item.name }}</text>
|
||||
<text v-if="isEditing" class="channel-tag__add">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { getArticleCate } from '@/api/news'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
channels: any[]
|
||||
activeIndex: number
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
channels: () => [],
|
||||
activeIndex: 0
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'change', channels: any[]): void
|
||||
(e: 'select', index: number): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const myChannels = ref<any[]>([])
|
||||
const allCateList = ref<any[]>([])
|
||||
|
||||
const statusBarHeight = ref(0)
|
||||
uni.getSystemInfo({
|
||||
success: (res) => {
|
||||
// #ifdef APP-PLUS || MP
|
||||
statusBarHeight.value = res.statusBarHeight || 44
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
statusBarHeight.value = 0
|
||||
// #endif
|
||||
}
|
||||
})
|
||||
|
||||
const recommendGroups = ref<{ title: string; channels: any[] }[]>([])
|
||||
|
||||
const loadAllCate = async () => {
|
||||
try {
|
||||
const data = await getArticleCate()
|
||||
allCateList.value = data || []
|
||||
} catch (e) {
|
||||
console.log('获取全部分类失败=>', e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
myChannels.value = [...props.channels]
|
||||
isEditing.value = false
|
||||
loadAllCate().then(() => filterRecommend())
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => visible.value, (val) => {
|
||||
if (!val) emit('update:show', false)
|
||||
})
|
||||
|
||||
const filterRecommend = () => {
|
||||
const myIds = new Set(myChannels.value.map(c => String(c.id)))
|
||||
const remaining = allCateList.value.filter(c => !myIds.has(String(c.id)))
|
||||
recommendGroups.value = remaining.length
|
||||
? [{ title: '推荐频道', channels: remaining }]
|
||||
: []
|
||||
}
|
||||
|
||||
const toggleEdit = () => {
|
||||
isEditing.value = !isEditing.value
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('change', [...myChannels.value])
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const handleMyChannelClick = (index: number) => {
|
||||
if (isEditing.value) {
|
||||
const item = myChannels.value[index]
|
||||
if (item.fixed) return
|
||||
myChannels.value.splice(index, 1)
|
||||
filterRecommend()
|
||||
} else {
|
||||
emit('select', index)
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddChannel = (item: any) => {
|
||||
myChannels.value.push({ ...item, fixed: false })
|
||||
filterRecommend()
|
||||
}
|
||||
|
||||
const goSearch = () => {
|
||||
visible.value = false
|
||||
uni.navigateTo({ url: '/pages/search/search' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.channel-manage {
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
&__close,
|
||||
&__search {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__scroll {
|
||||
height: calc(100vh - 120rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.channel-section {
|
||||
padding: 20rpx 30rpx;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: bold;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
&__edit {
|
||||
font-size: 26rpx;
|
||||
color: #185DFF;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.channel-tag {
|
||||
position: relative;
|
||||
min-width: 140rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #F4F5F7;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 28rpx;
|
||||
font-size: 28rpx;
|
||||
color: #808080;
|
||||
|
||||
&--active {
|
||||
color: #185DFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&--fixed {
|
||||
color: #185DFF;
|
||||
}
|
||||
|
||||
&--add {
|
||||
color: #185dff;
|
||||
background: #E5EDFF;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
margin-left: 8rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__add {
|
||||
margin-left: 8rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #185DFF;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<navigator :url="`/pages/news_detail/news_detail?id=${item.id}`" class="feed-card">
|
||||
<view class="feed-card__body">
|
||||
<view class="feed-card__info">
|
||||
<view class="feed-card__title">{{ item.title }}</view>
|
||||
<view v-if="descText" class="feed-card__desc">{{ descText }}</view>
|
||||
<view class="feed-card__footer">
|
||||
<text class="feed-card__author">{{ authorText }}</text>
|
||||
<text class="feed-card__meta feed-card__meta--center">{{ item.comment_count || item.click || 0
|
||||
}}评论</text>
|
||||
<text class="feed-card__meta feed-card__meta--right">{{ formatTime(item.create_time) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<UImage v-if="item.image" :src="item.image" width="220" height="140" border-radius="8" mode="aspectFill"
|
||||
class="feed-card__img" />
|
||||
</view>
|
||||
</navigator>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import UImage from '@/uni_modules/vk-uview-ui/components/u-image/u-image.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: any
|
||||
}>(),
|
||||
{
|
||||
item: () => ({})
|
||||
}
|
||||
)
|
||||
|
||||
const stripHtml = (html: string) => {
|
||||
if (!html) return ''
|
||||
return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
const descText = computed(() => {
|
||||
if (props.item.desc) return props.item.desc
|
||||
if (props.item.content) return stripHtml(props.item.content).substring(0, 100)
|
||||
return ''
|
||||
})
|
||||
|
||||
const authorText = computed(() => {
|
||||
const rawAuthor = String(props.item.author || '')
|
||||
const cleanedAuthor = rawAuthor.replace(/@懂球帝/g, '').trim()
|
||||
return cleanedAuthor || '体育小编'
|
||||
})
|
||||
|
||||
const formatTime = (t: string) => {
|
||||
if (!t) return ''
|
||||
const d = new Date(t.replace(/-/g, '/'))
|
||||
if (isNaN(d.getTime())) return t
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${m}-${day} ${h}:${min}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.feed-card {
|
||||
display: block;
|
||||
padding: 20rpx 20rpx;
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: 12rpx;
|
||||
gap: 12rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__author {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #bbb;
|
||||
white-space: nowrap;
|
||||
|
||||
&--center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&--right {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<TranslatedArticleCard :item="item" variant="feed" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TranslatedArticleCard from '@/components/translated-article-card/translated-article-card.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
item: any
|
||||
}>(),
|
||||
{
|
||||
item: () => ({})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<view class="video-card">
|
||||
<view class="video-card__main">
|
||||
<view class="video-card__info">
|
||||
<view class="video-card__title" @tap="goDetail">{{ item.title }}</view>
|
||||
<view v-if="descText" class="video-card__desc" @tap="goDetail">{{ descText }}</view>
|
||||
<view class="video-card__footer" @tap="goDetail">
|
||||
<text class="video-card__author">{{ item.author || '体育小编' }}</text>
|
||||
<text class="video-card__meta">{{ formatTime(item.create_time) }}</text>
|
||||
<view class="video-card__right">
|
||||
<text class="video-card__meta">{{ item.click || 0 }}播放</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="video-card__cover">
|
||||
<video v-if="playing" class="video-card__video" :src="videoSrc" :poster="item.image" :autoplay="true"
|
||||
:show-fullscreen-btn="true" :show-play-btn="true" :controls="true" object-fit="contain"
|
||||
@ended="playing = false" @error="handleVideoError" />
|
||||
<template v-else>
|
||||
<image class="video-card__img" :src="item.image || '/static/images/default_cover.png'"
|
||||
mode="aspectFill" />
|
||||
<view class="video-card__play" @tap.stop="handlePlay">
|
||||
<view class="video-card__play-btn">
|
||||
<view class="video-card__play-icon" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.duration" class="video-card__duration">
|
||||
<text>{{ item.duration }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="translatedPreview" class="video-card__translate" @tap="goDetail">
|
||||
<text class="video-card__translate-text">{{ translatedPreview }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: any
|
||||
}>(),
|
||||
{
|
||||
item: () => ({})
|
||||
}
|
||||
)
|
||||
|
||||
const playing = ref(false)
|
||||
|
||||
const extractVideoFromContent = (content: string): string => {
|
||||
if (!content) return ''
|
||||
const m = content.match(/src="(https?:\/\/[^\"]+\.mp4[^\"]*)"/i)
|
||||
return m ? m[1] : ''
|
||||
}
|
||||
|
||||
const videoSrc = computed(() => {
|
||||
if (props.item.video_url) return props.item.video_url
|
||||
return extractVideoFromContent(props.item.content)
|
||||
})
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!videoSrc.value) {
|
||||
uni.navigateTo({ url: `/pages/news_detail/news_detail?id=${props.item.id}` })
|
||||
return
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
const handleVideoError = () => {
|
||||
playing.value = false
|
||||
uni.showToast({ title: '视频加载失败', icon: 'none' })
|
||||
}
|
||||
|
||||
const goDetail = () => {
|
||||
uni.navigateTo({ url: `/pages/news_detail/news_detail?id=${props.item.id}` })
|
||||
}
|
||||
|
||||
const stripHtml = (html: string) => {
|
||||
if (!html) return ''
|
||||
return html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const descText = computed(() => {
|
||||
if (props.item.desc) return props.item.desc
|
||||
if (props.item.content) return stripHtml(props.item.content).substring(0, 100)
|
||||
return ''
|
||||
})
|
||||
|
||||
const normalizeText = (text: string) => {
|
||||
if (!text) return ''
|
||||
return text
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const truncateText = (text: string, maxLength = 90) => {
|
||||
if (!text) return ''
|
||||
if (text.length <= maxLength) return text
|
||||
return `${text.slice(0, maxLength).trimEnd()}...`
|
||||
}
|
||||
|
||||
const translatedPreview = computed(() => {
|
||||
const text = normalizeText(props.item.translated_content || '')
|
||||
return truncateText(text, 90)
|
||||
})
|
||||
|
||||
const formatTime = (t: string) => {
|
||||
if (!t) return ''
|
||||
const d = new Date(t.replace(/-/g, '/'))
|
||||
if (isNaN(d.getTime())) return t
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${m}-${day} ${h}:${min}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.video-card {
|
||||
display: block;
|
||||
padding: 24rpx 30rpx;
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
&__main {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
color: #808080;
|
||||
margin-top: -8rpx;
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__cover {
|
||||
position: relative;
|
||||
width: 260rpx;
|
||||
height: 380rpx;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__play {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__play-btn {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__play-icon {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 14rpx 0 14rpx 24rpx;
|
||||
border-color: transparent transparent transparent #185dff;
|
||||
margin-left: 6rpx;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
position: absolute;
|
||||
right: 16rpx;
|
||||
bottom: 16rpx;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
&__translate {
|
||||
margin-top: 10rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-left: 4rpx;
|
||||
}
|
||||
|
||||
&__translate-text {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #3b4b66;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 16rpx;
|
||||
gap: 20rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__author {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 24rpx;
|
||||
color: #bbb;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__right {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<view v-if="visible && current" class="global-popup-mask" @tap="handleMaskTap">
|
||||
<view class="global-popup-wrap" @tap.stop>
|
||||
<view v-if="showAutoCloseProgress" class="global-popup__progress">
|
||||
<view class="global-popup__progress-inner" :style="progressStyle"></view>
|
||||
</view>
|
||||
<view class="global-popup">
|
||||
<!-- 关闭按钮 -->
|
||||
<view v-if="current.is_closable !== 0" class="global-popup__close" @tap="handleClose">
|
||||
<text>×</text>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<view class="global-popup__body" @tap="handleClick">
|
||||
<image v-if="current.image" :src="current.image" class="global-popup__image" mode="widthFix" />
|
||||
|
||||
<view v-if="current.title || current.content" class="global-popup__text">
|
||||
<text v-if="current.title" class="global-popup__title">{{ current.title }}</text>
|
||||
<rich-text v-if="current.content" class="global-popup__content"
|
||||
:nodes="current.content"></rich-text>
|
||||
</view>
|
||||
|
||||
<view v-if="current.link_type !== 0" class="global-popup__btn">
|
||||
<text>查看详情</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { getPopupList, reportPopupAction, type PagePopupItem } from '@/api/popup'
|
||||
import { getOrCreateDeviceId, getPlatform } from '@/utils/device'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const props = defineProps<{
|
||||
pagePath?: string
|
||||
triggerTick?: number
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
function getPagePathFromPages(): string {
|
||||
const pages = getCurrentPages()
|
||||
if (!pages.length) return ''
|
||||
const page = pages[pages.length - 1] as any
|
||||
let route = page.route || ''
|
||||
// #ifdef H5
|
||||
route = route.replace(/^\//, '')
|
||||
if (route.startsWith('mobile/')) {
|
||||
route = route.replace('mobile/', '')
|
||||
}
|
||||
// #endif
|
||||
return '/' + route
|
||||
}
|
||||
|
||||
const effectivePath = computed(() => props.pagePath || appStore.popupPagePath || getPagePathFromPages())
|
||||
const effectiveTick = computed(() => props.triggerTick ?? appStore.popupTick)
|
||||
|
||||
const visible = ref(false)
|
||||
const current = ref<PagePopupItem | null>(null)
|
||||
const showAutoCloseProgress = ref(false)
|
||||
const progressPct = ref(100)
|
||||
let autoCloseInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function progressColor(pct: number): string {
|
||||
if (pct > 60) return '#4cd964'
|
||||
if (pct > 30) return '#ff9500'
|
||||
return '#ff3b30'
|
||||
}
|
||||
|
||||
const progressStyle = computed(() => ({
|
||||
width: progressPct.value + '%',
|
||||
backgroundColor: progressColor(progressPct.value),
|
||||
transitionDuration: '200ms',
|
||||
}))
|
||||
const platform = getPlatform()
|
||||
const deviceId = getOrCreateDeviceId()
|
||||
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 频率控制存储 key
|
||||
const FREQ_KEY_PREFIX = 'popup_shown_'
|
||||
|
||||
/** 同一 App 启动周期内已展示的弹窗 ID 集合(每会话一次) */
|
||||
const sessionShown = new Set<number>()
|
||||
|
||||
/**
|
||||
* 检查频率是否允许展示
|
||||
* @returns true 表示可以展示
|
||||
*/
|
||||
function checkFrequency(item: PagePopupItem): boolean {
|
||||
const key = FREQ_KEY_PREFIX + item.id
|
||||
const now = Date.now()
|
||||
|
||||
switch (item.frequency) {
|
||||
case 1: // 每次
|
||||
return true
|
||||
case 2: // 每会话一次
|
||||
return !sessionShown.has(item.id)
|
||||
case 3: {
|
||||
// 每天一次
|
||||
try {
|
||||
const last = uni.getStorageSync(key)
|
||||
if (!last) return true
|
||||
return now - Number(last) >= 24 * 60 * 60 * 1000
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case 4: {
|
||||
// 仅一次
|
||||
try {
|
||||
return !uni.getStorageSync(key)
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** 标记已展示 */
|
||||
function markShown(item: PagePopupItem) {
|
||||
sessionShown.add(item.id)
|
||||
if (item.frequency === 3 || item.frequency === 4) {
|
||||
try {
|
||||
uni.setStorageSync(FREQ_KEY_PREFIX + item.id, Date.now())
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取弹窗并展示首个匹配项 */
|
||||
async function checkAndShow() {
|
||||
if (!effectivePath.value) return
|
||||
try {
|
||||
const res: any = await getPopupList({
|
||||
page_path: effectivePath.value,
|
||||
platform,
|
||||
})
|
||||
const lists: PagePopupItem[] = res?.lists || []
|
||||
if (!lists.length) return
|
||||
|
||||
// 取第一个频率允许展示的弹窗
|
||||
const target = lists.find((it) => checkFrequency(it))
|
||||
if (!target) return
|
||||
|
||||
const showFn = () => {
|
||||
current.value = target
|
||||
visible.value = true
|
||||
markShown(target)
|
||||
startAutoClose(target)
|
||||
// 上报展示
|
||||
reportPopupAction({
|
||||
popup_id: target.id,
|
||||
action: 1,
|
||||
page_path: effectivePath.value,
|
||||
device_id: deviceId,
|
||||
platform,
|
||||
}).catch(() => { /* ignore */ })
|
||||
}
|
||||
|
||||
if (target.delay_seconds && target.delay_seconds > 0) {
|
||||
setTimeout(showFn, target.delay_seconds * 1000)
|
||||
} else {
|
||||
showFn()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[GlobalPopup] 拉取弹窗失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!current.value) return
|
||||
const item = current.value
|
||||
// 上报点击
|
||||
reportPopupAction({
|
||||
popup_id: item.id,
|
||||
action: 2,
|
||||
page_path: effectivePath.value,
|
||||
device_id: deviceId,
|
||||
platform,
|
||||
}).catch(() => { /* ignore */ })
|
||||
|
||||
// 跳转
|
||||
if (item.link_type === 1 && item.link_url) {
|
||||
// 站内
|
||||
uni.navigateTo({
|
||||
url: item.link_url,
|
||||
fail: () => {
|
||||
uni.switchTab({
|
||||
url: item.link_url,
|
||||
fail: () => uni.reLaunch({ url: item.link_url }),
|
||||
})
|
||||
},
|
||||
})
|
||||
closeWithoutReport()
|
||||
} else if (item.link_type === 2 && item.link_url) {
|
||||
// 外部 H5
|
||||
// #ifdef APP-PLUS
|
||||
plus.runtime.openURL(item.link_url)
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
window.open(item.link_url, '_blank')
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
uni.setClipboardData({ data: item.link_url })
|
||||
uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||
// #endif
|
||||
closeWithoutReport()
|
||||
} else if (item.link_type === 3 && item.link_url) {
|
||||
// 拨打电话
|
||||
uni.makePhoneCall({ phoneNumber: item.link_url })
|
||||
closeWithoutReport()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (!current.value) return
|
||||
clearAutoClose()
|
||||
reportPopupAction({
|
||||
popup_id: current.value.id,
|
||||
action: 3,
|
||||
page_path: effectivePath.value,
|
||||
device_id: deviceId,
|
||||
platform,
|
||||
}).catch(() => { /* ignore */ })
|
||||
closeWithoutReport()
|
||||
}
|
||||
|
||||
function handleMaskTap() {
|
||||
if (current.value && current.value.is_closable === 0) return
|
||||
handleClose()
|
||||
}
|
||||
|
||||
function closeWithoutReport() {
|
||||
clearAutoClose()
|
||||
visible.value = false
|
||||
current.value = null
|
||||
}
|
||||
|
||||
function closeWithReport() {
|
||||
if (!current.value) return
|
||||
reportPopupAction({
|
||||
popup_id: current.value.id,
|
||||
action: 3,
|
||||
page_path: effectivePath.value,
|
||||
device_id: deviceId,
|
||||
platform,
|
||||
}).catch(() => { /* ignore */ })
|
||||
closeWithoutReport()
|
||||
}
|
||||
|
||||
function startAutoClose(item: PagePopupItem) {
|
||||
clearAutoClose()
|
||||
const seconds = Number(item.auto_close_seconds || 0)
|
||||
if (seconds <= 0) return
|
||||
const duration = seconds * 1000
|
||||
const step = 200
|
||||
const decrement = (100 * step) / duration
|
||||
progressPct.value = 100
|
||||
showAutoCloseProgress.value = true
|
||||
autoCloseInterval = setInterval(() => {
|
||||
progressPct.value = Math.max(0, progressPct.value - decrement)
|
||||
}, step)
|
||||
autoCloseTimer = setTimeout(() => {
|
||||
closeWithReport()
|
||||
}, duration)
|
||||
}
|
||||
|
||||
function clearAutoClose() {
|
||||
if (autoCloseTimer) {
|
||||
clearTimeout(autoCloseTimer)
|
||||
autoCloseTimer = null
|
||||
}
|
||||
if (autoCloseInterval) {
|
||||
clearInterval(autoCloseInterval)
|
||||
autoCloseInterval = null
|
||||
}
|
||||
showAutoCloseProgress.value = false
|
||||
progressPct.value = 100
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAndShow()
|
||||
})
|
||||
|
||||
watch(effectiveTick, () => {
|
||||
if (!visible.value) checkAndShow()
|
||||
})
|
||||
|
||||
watch(effectivePath, () => {
|
||||
if (!visible.value) checkAndShow()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.global-popup-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.global-popup-wrap {
|
||||
position: relative;
|
||||
width: 580rpx;
|
||||
}
|
||||
|
||||
.global-popup {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
animation: globalPopupIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes globalPopupIn {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.global-popup__close {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
|
||||
text {
|
||||
font-size: 48rpx;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.global-popup__progress {
|
||||
width: 100%;
|
||||
height: 6rpx;
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.global-popup__progress-inner {
|
||||
height: 100%;
|
||||
border-radius: 999rpx;
|
||||
transition-property: width, background-color;
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
|
||||
.global-popup__body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.global-popup__image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.global-popup__text {
|
||||
padding: 32rpx 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.global-popup__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.global-popup__content {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.global-popup__btn {
|
||||
margin: 0 40rpx 32rpx;
|
||||
background: #0b55eb;
|
||||
border-radius: 40rpx;
|
||||
padding: 24rpx 0;
|
||||
text-align: center;
|
||||
|
||||
text {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<u-swiper
|
||||
v-if="lists.length"
|
||||
:list="lists"
|
||||
:mode="mode"
|
||||
:height="height"
|
||||
:effect3d="effect3d"
|
||||
:indicator-pos="indicatorPos"
|
||||
:autoplay="autoplay"
|
||||
:interval="interval"
|
||||
:duration="duration"
|
||||
:circular="circular"
|
||||
:borderRadius="borderRadius"
|
||||
:current="current"
|
||||
:name="name"
|
||||
:bg-color="bgColor"
|
||||
@click="handleClick"
|
||||
@change="handleChange"
|
||||
>
|
||||
</u-swiper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useAppStore} from "@/stores/app";
|
||||
import {navigateTo, navigateToMiniProgram, LinkTypeEnum} from "@/utils/util";
|
||||
import {watchEffect, computed} from "vue";
|
||||
import {useRouter} from "uniapp-router-next";
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content?: any; // 轮播图数据
|
||||
mode?: string; // 指示器模式 rect / dot / number / none
|
||||
height?: string; // 轮播图组件高度
|
||||
indicatorPos?: string; // 指示器的位置 topLeft / topCenter / topRight / bottomLeft / bottomRight
|
||||
effect3d?: boolean; // 是否开启3D效果
|
||||
autoplay?: boolean; // 是否自动播放
|
||||
interval?: number | string; // 自动轮播时间间隔,单位ms
|
||||
duration?: number | string; // 切换一张轮播图所需的时间,单位ms
|
||||
circular?: boolean; // 是否衔接播放
|
||||
current?: number; // 默认显示第几项
|
||||
name?: string; // 显示的属性
|
||||
borderRadius?: string; //轮播图圆角值,单位rpx
|
||||
bgColor?: string; // 背景颜色
|
||||
}>(),
|
||||
{
|
||||
content: {
|
||||
data: [],
|
||||
},
|
||||
mode: "round",
|
||||
indicatorPos: "bottomCenter",
|
||||
height: "340",
|
||||
effect3d: false,
|
||||
autoplay: true,
|
||||
interval: "2500",
|
||||
duration: 300,
|
||||
circular: true,
|
||||
current: 0,
|
||||
name: "image",
|
||||
borderRadius: "0",
|
||||
bgColor: "#f3f4f6",
|
||||
}
|
||||
);
|
||||
|
||||
const {getImageUrl} = useAppStore();
|
||||
|
||||
watchEffect(() => {
|
||||
try {
|
||||
const content = props?.content;
|
||||
const len = content?.data?.length;
|
||||
if (!len) return;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const item = content.data[i];
|
||||
item.image = getImageUrl(item.image);
|
||||
}
|
||||
emit("change", 0);
|
||||
} catch (error) {
|
||||
//TODO handle the exception
|
||||
console.log("轮播图数据错误", error);
|
||||
}
|
||||
});
|
||||
|
||||
const lists = computed(() => props.content.data || []);
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
const link = props.content.data[index]?.link;
|
||||
if (!link) return
|
||||
navigateTo(link);
|
||||
};
|
||||
|
||||
const handleChange = (index: number) => {
|
||||
emit("change", index);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,591 @@
|
||||
<template>
|
||||
<view class="mc" :class="'mc--' + cardType">
|
||||
<!-- 顶部:赛事标签行 -->
|
||||
<view class="mc__tags">
|
||||
<view class="mc__tags-left">
|
||||
<text class="mc__tag-label">{{ match.league_name || '赛事' }}</text>
|
||||
<view class="mc__tag-pill">
|
||||
<text>联赛</text>
|
||||
</view>
|
||||
<view v-if="match.round_name" class="mc__tag-pill">
|
||||
<text>{{ match.round_name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mc__tags-right">
|
||||
<text
|
||||
v-if="showTagTime"
|
||||
class="mc__time"
|
||||
:class="'mc__time--' + cardType"
|
||||
>
|
||||
{{ scheduleTime }}
|
||||
</text>
|
||||
<view v-if="match.is_hot" class="mc__tag-hot">
|
||||
<text>热门</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 中间:主客队 + 比分 -->
|
||||
<view v-if="match.status === 1" class="mc__body-live" :style="liveStyle">
|
||||
<view class="mc__bg-left"></view>
|
||||
<view class="mc__bg-right"></view>
|
||||
<view class="mc__home mc__home--live">
|
||||
<image v-if="match.home_icon" class="mc__icon" :src="match.home_icon" mode="aspectFit" />
|
||||
<text class="mc__name mc__name--white">{{ match.home_team }}</text>
|
||||
<text class="mc__eng mc__eng--white">{{ match.home_team_en || '' }}</text>
|
||||
</view>
|
||||
<view class="mc__vs-area mc__vs-area--live">
|
||||
<text class="mc__num mc__num--white">{{ match.home_score ?? 0 }}</text>
|
||||
<text class="mc__vs-text mc__vs-text--live">VS</text>
|
||||
<text class="mc__num mc__num--white">{{ match.away_score ?? 0 }}</text>
|
||||
</view>
|
||||
<view class="mc__away mc__away--live">
|
||||
<image v-if="match.away_icon" class="mc__icon" :src="match.away_icon" mode="aspectFit" />
|
||||
<text class="mc__name mc__name--white">{{ match.away_team }}</text>
|
||||
<text class="mc__eng mc__eng--white">{{ match.away_team_en || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="match.status === 0" class="mc__body-upcoming">
|
||||
<view class="mc__row">
|
||||
<view class="mc__team-row">
|
||||
<image v-if="match.home_icon" class="mc__icon-sm" :src="match.home_icon" mode="aspectFit" />
|
||||
<view class="mc__team-info">
|
||||
<text class="mc__tname">{{ match.home_team }}</text>
|
||||
<text class="mc__teng">{{ match.home_team_en || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mc__odds-item">
|
||||
<text class="mc__odds-label">主胜</text>
|
||||
<text class="mc__odds-val">{{ match.home_odds || '0.00' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mc__row mc__row--mid">
|
||||
<view class="mc__vs-center">
|
||||
<text class="mc__vs-text">VS</text>
|
||||
</view>
|
||||
<view class="mc__odds-item">
|
||||
<text class="mc__odds-label">平局</text>
|
||||
<text class="mc__odds-val">{{ match.draw_odds || '0.00' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mc__row">
|
||||
<view class="mc__team-row mc__team-row--away">
|
||||
<image v-if="match.away_icon" class="mc__icon-sm" :src="match.away_icon" mode="aspectFit" />
|
||||
<view class="mc__team-info">
|
||||
<text class="mc__tname">{{ match.away_team }}</text>
|
||||
<text class="mc__teng">{{ match.away_team_en || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mc__odds-item">
|
||||
<text class="mc__odds-label">客胜</text>
|
||||
<text class="mc__odds-val">{{ match.away_odds || '0.00' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="mc__body">
|
||||
<view class="mc__home">
|
||||
<image v-if="match.home_icon" class="mc__icon" :src="match.home_icon" mode="aspectFit" />
|
||||
<text class="mc__name">{{ match.home_team }}</text>
|
||||
<text class="mc__eng">{{ match.home_team_en || '' }}</text>
|
||||
</view>
|
||||
<view class="mc__vs-area">
|
||||
<text class="mc__num">{{ match.home_score ?? 0 }}</text>
|
||||
<text class="mc__vs-text">VS</text>
|
||||
<text class="mc__num">{{ match.away_score ?? 0 }}</text>
|
||||
</view>
|
||||
<view class="mc__away">
|
||||
<image v-if="match.away_icon" class="mc__icon" :src="match.away_icon" mode="aspectFit" />
|
||||
<text class="mc__name">{{ match.away_team }}</text>
|
||||
<text class="mc__eng">{{ match.away_team_en || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 底部:状态 + 时间 -->
|
||||
<view class="mc__footer" :class="{ 'mc__footer--live-ai': canShowLiveAi }">
|
||||
<view class="mc__badge" :class="'mc__badge--' + cardType">
|
||||
<text>{{ statusLabel }}</text>
|
||||
</view>
|
||||
<text v-if="showFooterTime" class="mc__time" :class="'mc__time--' + cardType">{{ scheduleTime }}</text>
|
||||
<view v-if="canShowLiveAi" class="mc__ai-entry" @tap.stop="goAiAnalysis">
|
||||
<text class="mc__ai-entry-badge">AI</text>
|
||||
<text class="mc__ai-entry-text">分析</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
match: any
|
||||
showLiveAi?: boolean
|
||||
}>()
|
||||
|
||||
const statusTypeMap: Record<number, string> = { 1: 'live', 0: 'upcoming', 3: 'postponed', 2: 'ended' }
|
||||
const statusTextMap: Record<number, string> = { 1: '进行中', 0: '未开始', 3: '推迟', 2: '已结束' }
|
||||
|
||||
const sportColors: Record<number, { left: string; right: string }> = {
|
||||
1: { left: '#008248', right: '#00471b' },
|
||||
2: { left: '#006BB7', right: '#D0202D' },
|
||||
4: { left: '#6B3FA0', right: '#3F1D6B' },
|
||||
5: { left: '#C47A20', right: '#8B5512' }
|
||||
}
|
||||
const defaultSportColor = { left: '#008248', right: '#00471b' }
|
||||
|
||||
const liveStyle = computed(() => {
|
||||
const c = sportColors[props.match.sport_type] || defaultSportColor
|
||||
return {
|
||||
'--bg-left-color': c.left,
|
||||
'--bg-right-color': c.right
|
||||
}
|
||||
})
|
||||
|
||||
const cardType = computed(() => statusTypeMap[props.match.status] || 'other')
|
||||
|
||||
const canShowLiveAi = computed(() => {
|
||||
return !!props.showLiveAi && ['live', 'upcoming'].includes(cardType.value) && !!props.match?.id
|
||||
})
|
||||
|
||||
const showTagTime = computed(() => {
|
||||
return ['live', 'upcoming', 'ended'].includes(cardType.value)
|
||||
})
|
||||
|
||||
const showFooterTime = computed(() => {
|
||||
return !showTagTime.value
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (props.match.status === 1) return '· 进行中 ·'
|
||||
return statusTextMap[props.match.status] || '未开始'
|
||||
})
|
||||
|
||||
const scheduleTime = computed(() => {
|
||||
if (!props.match.match_time) return ''
|
||||
const d = new Date(props.match.match_time * 1000)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const date = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${date} ${hours}:${minutes}`
|
||||
})
|
||||
|
||||
const goAiAnalysis = () => {
|
||||
const id = props.match?.id
|
||||
if (!id) return
|
||||
uni.navigateTo({ url: `/pages/ai_analysis/ai_analysis?type=match&id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mc {
|
||||
padding: 20rpx 26rpx 10rpx;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
|
||||
&--ended {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__tags-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
&__tags-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
justify-content: flex-end;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
&__tag-label {
|
||||
font-size: 24rpx;
|
||||
color: #808080;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__tag-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rpx 8rpx;
|
||||
background: #e5edff;
|
||||
border-radius: 6rpx;
|
||||
|
||||
text {
|
||||
font-size: 20rpx;
|
||||
color: #185dff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&__tag-hot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rpx 8rpx;
|
||||
background: #ffe5e5;
|
||||
border-radius: 6rpx;
|
||||
|
||||
text {
|
||||
font-size: 20rpx;
|
||||
color: #ee3835;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&__body-upcoming {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
&--mid {
|
||||
padding-left: 56rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&__team-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__team-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__icon-sm {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tname {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__teng {
|
||||
font-size: 24rpx;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__vs-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__odds-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
&__odds-label {
|
||||
font-size: 24rpx;
|
||||
color: #808080;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__odds-val {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #185dff;
|
||||
line-height: 1.4;
|
||||
width: 64rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__body-live {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
&__bg-left {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -8%;
|
||||
width: 56%;
|
||||
height: 100%;
|
||||
background: var(--bg-left-color, #008248);
|
||||
transform: skewX(-35deg);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&__bg-right {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -8%;
|
||||
width: 56%;
|
||||
height: 100%;
|
||||
background: var(--bg-right-color, #00471b);
|
||||
transform: skewX(-35deg);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&__home {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
min-width: 0;
|
||||
|
||||
&--live {
|
||||
padding: 20rpx 0 20rpx 40rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&__away {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
min-width: 0;
|
||||
|
||||
&--live {
|
||||
padding: 20rpx 40rpx 20rpx 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
|
||||
&--white {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__eng {
|
||||
font-size: 24rpx;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
|
||||
&--white {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__vs-area {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32rpx;
|
||||
padding: 0 24rpx;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
|
||||
&--live {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&__num {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
|
||||
&--white {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__vs-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #808080;
|
||||
line-height: 1.4;
|
||||
|
||||
&--live {
|
||||
color: #fff;
|
||||
text-shadow: 0 4rpx 6rpx rgba(0, 0, 0, 0.25);
|
||||
-webkit-text-stroke: 2rpx #ee3835;
|
||||
paint-order: stroke fill;
|
||||
background: linear-gradient(180deg, #ee3835 0%, #185dff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-stroke-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
|
||||
&--live-ai {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&__ai-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
min-width: 152rpx;
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #f4f7ff;
|
||||
|
||||
&-badge {
|
||||
min-width: 40rpx;
|
||||
height: 40rpx;
|
||||
padding: 0 10rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, #185dff 0%, #4f7dff 100%);
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #185dff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 32rpx;
|
||||
border-radius: 24rpx;
|
||||
|
||||
text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&--upcoming {
|
||||
background: #e5edff;
|
||||
|
||||
text {
|
||||
color: #185dff;
|
||||
}
|
||||
}
|
||||
|
||||
&--live {
|
||||
background: #185dff;
|
||||
|
||||
text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--ended {
|
||||
background: #808080;
|
||||
|
||||
text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--postponed {
|
||||
background: #fff1dd;
|
||||
|
||||
text {
|
||||
color: #ff9900;
|
||||
}
|
||||
}
|
||||
|
||||
&--other {
|
||||
background: #f0f0f0;
|
||||
|
||||
text {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 24rpx;
|
||||
color: #808080;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
|
||||
&--live {
|
||||
color: #185dff;
|
||||
}
|
||||
|
||||
&--upcoming {
|
||||
color: #185dff;
|
||||
}
|
||||
|
||||
&--ended {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<view class="detail-header" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="detail-header__nav">
|
||||
<view class="detail-header__back" @tap="$emit('back')">
|
||||
<u-icon name="arrow-left" size="40" color="#333"></u-icon>
|
||||
</view>
|
||||
<view class="detail-header__title-wrap">
|
||||
<text class="detail-header__title">{{ title }}</text>
|
||||
<text class="detail-header__subtitle">{{ subtitle }}</text>
|
||||
</view>
|
||||
<view class="detail-header__share" @tap="$emit('share')">
|
||||
<u-icon name="share" size="36" color="#333"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
statusBarHeight?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'back'): void
|
||||
(e: 'share'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0 24rpx 16rpx;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
&__back,
|
||||
&__share {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 20rpx;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&__title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
max-width: 420rpx;
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<view>
|
||||
<u-popup v-model="showPopup" mode="bottom" border-radius="14" :mask-close-able="false">
|
||||
<view class="h-[1000rpx] p-[40rpx]">
|
||||
<view class="flex items-center">
|
||||
<image
|
||||
class="w-[100rpx] h-[100rpx] rounded"
|
||||
mode="heightFix"
|
||||
:src="logo"
|
||||
></image>
|
||||
<text class="text-3xl ml-5 font-bold">{{ title }}</text>
|
||||
</view>
|
||||
<view class="mt-5 text-muted">
|
||||
建议使用您的微信头像和昵称,以便获得更好的体验
|
||||
</view>
|
||||
<view class="mt-[30rpx]">
|
||||
<form @submit="handleSubmit">
|
||||
<u-form-item required label="头像" :labelWidth="120">
|
||||
<view class="flex-1">
|
||||
<avatar-upload v-model="avatar"></avatar-upload>
|
||||
</view>
|
||||
</u-form-item>
|
||||
<u-form-item required label="昵称" :labelWidth="120">
|
||||
<input
|
||||
class="flex-1 h-[60rpx]"
|
||||
name="nickname"
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</u-form-item>
|
||||
<view class="mt-[80rpx]">
|
||||
<button
|
||||
class="bg-primary rounded-full text-white text-lg h-[80rpx] leading-[80rpx]"
|
||||
hover-class="none"
|
||||
form-type="submit"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="flex justify-center mt-[60rpx]">
|
||||
<view class="text-muted" @click="showPopup = false">暂不登录</view>
|
||||
</view>
|
||||
</form>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean
|
||||
},
|
||||
logo: {
|
||||
type: String
|
||||
},
|
||||
title: {
|
||||
type: String
|
||||
}
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:show', show: boolean): void
|
||||
(event: 'update', value: any): void
|
||||
}>()
|
||||
|
||||
const showPopup = computed({
|
||||
get() {
|
||||
return props.show
|
||||
},
|
||||
set(val) {
|
||||
emit('update:show', val)
|
||||
}
|
||||
})
|
||||
|
||||
const avatar = ref()
|
||||
|
||||
const handleSubmit = (e: any) => {
|
||||
const { nickname } = e.detail.value
|
||||
if (!avatar.value)
|
||||
return uni.$u.toast('请添加头像')
|
||||
if (!nickname)
|
||||
return uni.$u.toast('请输入昵称')
|
||||
emit('update', {
|
||||
avatar: avatar.value,
|
||||
nickname
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<view class="news-card flex bg-white px-[20rpx] py-[32rpx]" @tap="goDetail">
|
||||
<view class="mr-[20rpx]" v-if="item.image">
|
||||
<u-image :src="item.image" width="240" height="180"></u-image>
|
||||
</view>
|
||||
<view class="news-card-content flex flex-col justify-between flex-1">
|
||||
<view class="news-card-content-title text-base">{{ item.title }}</view>
|
||||
<view class="news-card-content-intro text-gray-400 text-sm mt-[16rpx]">
|
||||
{{ item.desc }}
|
||||
</view>
|
||||
|
||||
<view class="text-muted text-xs w-full flex justify-between mt-[12rpx]">
|
||||
<view>{{ item.create_time }}</view>
|
||||
<view class="flex items-center">
|
||||
<image src="/static/images/icon/icon_visit.png" class="w-[30rpx] h-[30rpx]"></image>
|
||||
<view class="ml-[10rpx]">{{ item.click }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="enableTranslate" class="news-card-translate">
|
||||
<view class="news-card-translate__btn" @tap.stop="emit('translate', item)">
|
||||
<text>AI翻译</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: any
|
||||
newsId: number
|
||||
enableTranslate?: boolean
|
||||
}>(),
|
||||
{
|
||||
item: {},
|
||||
newsId: '',
|
||||
enableTranslate: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['translate'])
|
||||
|
||||
const goDetail = () => {
|
||||
uni.navigateTo({ url: `/pages/news_detail/news_detail?id=${props.newsId}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.news-card {
|
||||
border-bottom: 1px solid #f8f8f8;
|
||||
|
||||
&-content {
|
||||
&-title {
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&-intro {
|
||||
-webkit-line-clamp: 1;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
&-translate {
|
||||
margin-top: 16rpx;
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, #0ea5e9, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<view
|
||||
class="page-status"
|
||||
v-if="status !== PageStatusEnum['NORMAL']"
|
||||
:class="{ 'page-status--fixed': fixed }"
|
||||
>
|
||||
<!-- Loading -->
|
||||
<template v-if="status === PageStatusEnum['LOADING']">
|
||||
<slot name="loading">
|
||||
<u-loading :size="60" mode="flower" />
|
||||
</slot>
|
||||
</template>
|
||||
<!-- Error -->
|
||||
<template v-if="status === PageStatusEnum['ERROR']">
|
||||
<slot name="error"></slot>
|
||||
</template>
|
||||
<!-- Empty -->
|
||||
<template v-if="status === PageStatusEnum['EMPTY']">
|
||||
<slot name="empty"></slot>
|
||||
</template>
|
||||
</view>
|
||||
<template v-else>
|
||||
<slot> </slot>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PageStatusEnum } from '@/enums/appEnums'
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: PageStatusEnum['LOADING']
|
||||
},
|
||||
fixed: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-status {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
&--fixed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 900;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<u-popup v-model="showPay" mode="bottom" safe-area-inset-bottom :mask-close-able="false" border-radius="14"
|
||||
closeable @close="handleClose">
|
||||
<view class="h-[900rpx]">
|
||||
<page-status :status="popupStatus" :fixed="false">
|
||||
<template #error>
|
||||
<u-empty text="订单信息错误,无法查询到订单信息" mode="order"></u-empty>
|
||||
</template>
|
||||
<template #default>
|
||||
<view class="payment h-full w-full flex flex-col">
|
||||
<view class="header py-[50rpx] flex flex-col items-center">
|
||||
<price :content="payData.order_amount" mainSize="44rpx" minorSize="40rpx" fontWeight="500"
|
||||
color="#333"></price>
|
||||
</view>
|
||||
<scroll-view scroll-y class="main flex-1 mx-[20rpx]">
|
||||
<view class="payway-lists">
|
||||
<u-radio-group v-model="payWay" class="w-full">
|
||||
<view class="p-[20rpx] flex items-center w-full payway-item"
|
||||
v-for="(item, index) in payData.lists" :key="index"
|
||||
@click="selectPayWay(item.pay_way)">
|
||||
<u-icon class="flex-none" :size="48" :name="item.icon"></u-icon>
|
||||
<view class="mx-[16rpx] flex-1">
|
||||
<view class="payway-item--name flex-1">
|
||||
{{ item.name }}
|
||||
</view>
|
||||
<view class="text-muted text-xs">{{
|
||||
item.extra
|
||||
}}</view>
|
||||
</view>
|
||||
|
||||
<u-radio activeColor="#185dff" class="mr-[-20rpx]" :name="item.pay_way">
|
||||
</u-radio>
|
||||
</view>
|
||||
</u-radio-group>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="submit-btn p-[20rpx]">
|
||||
<u-button @click="handlePay" shape="circle" type="primary" :loading="isLock">
|
||||
立即支付
|
||||
</u-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</page-status>
|
||||
</view>
|
||||
</u-popup>
|
||||
|
||||
<u-popup class="pay-popup" v-model="showCheckPay" round mode="center" borderRadius="10" :maskCloseAble="false">
|
||||
<view class="content bg-white w-[560rpx] p-[40rpx]">
|
||||
<view class="text-2xl font-medium text-center"> 支付确认 </view>
|
||||
<view class="pt-[30rpx] pb-[40rpx]">
|
||||
<view> 请在微信内完成支付,如果您已支付成功,请点击`已完成支付`按钮 </view>
|
||||
</view>
|
||||
<view class="flex">
|
||||
<view class="flex-1 mr-[20rpx]">
|
||||
<u-button shape="circle" type="primary" plain size="medium" hover-class="none"
|
||||
:customStyle="{ width: '100%' }" @click="queryPayResult(false)">
|
||||
重新支付
|
||||
</u-button>
|
||||
</view>
|
||||
<view class="flex-1">
|
||||
<u-button shape="circle" type="primary" size="medium" hover-class="none"
|
||||
:customStyle="{ width: '100%' }" @click="queryPayResult()">
|
||||
已完成支付
|
||||
</u-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { pay, PayWayEnum } from '@/utils/pay'
|
||||
import { getPayWay, prepay, getPayResult } from '@/api/pay'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useLockFn } from '@/hooks/useLockFn'
|
||||
import { series } from '@/utils/util'
|
||||
import { ClientEnum, PageStatusEnum, PayStatusEnum } from '@/enums/appEnums'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { client } from '@/utils/client'
|
||||
/*
|
||||
页面参数 orderId:订单id,from:订单来源
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
showCheck: {
|
||||
type: Boolean
|
||||
},
|
||||
// 订单id
|
||||
orderId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
//订单来源
|
||||
from: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
//h5微信支付回跳路径,一般为拉起支付的页面路径
|
||||
redirect: {
|
||||
type: String
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:showCheck', 'update:show', 'close', 'success', 'fail'])
|
||||
|
||||
const payWay = ref()
|
||||
const popupStatus = ref(PageStatusEnum.LOADING)
|
||||
const payData = ref<any>({
|
||||
order_amount: '',
|
||||
lists: []
|
||||
})
|
||||
|
||||
const showCheckPay = computed({
|
||||
get() {
|
||||
return props.showCheck
|
||||
},
|
||||
set(value) {
|
||||
emit('update:showCheck', value)
|
||||
}
|
||||
})
|
||||
|
||||
const showPay = computed({
|
||||
get() {
|
||||
return props.show
|
||||
},
|
||||
set(value) {
|
||||
emit('update:show', value)
|
||||
}
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
showPay.value = false
|
||||
emit('close')
|
||||
}
|
||||
const getPayData = async () => {
|
||||
popupStatus.value = PageStatusEnum.LOADING
|
||||
try {
|
||||
payData.value = await getPayWay({
|
||||
order_id: props.orderId,
|
||||
from: props.from
|
||||
})
|
||||
popupStatus.value = PageStatusEnum.NORMAL
|
||||
const checkPay =
|
||||
payData.value.lists.find((item: any) => item.is_default) || payData.value.lists[0]
|
||||
payWay.value = checkPay?.pay_way
|
||||
} catch (error) {
|
||||
popupStatus.value = PageStatusEnum.ERROR
|
||||
}
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
const selectPayWay = (pay: number) => {
|
||||
payWay.value = pay
|
||||
}
|
||||
const payment = (() => {
|
||||
// 查询是否绑定微信
|
||||
const checkIsBindWx = async () => {
|
||||
if (
|
||||
userStore.userInfo.is_auth == 0 &&
|
||||
[ClientEnum.OA_WEIXIN, ClientEnum.MP_WEIXIN].includes(client) &&
|
||||
payWay.value == PayWayEnum.WECHAT
|
||||
) {
|
||||
const res: any = await uni.showModal({
|
||||
title: '温馨提示',
|
||||
content: '当前账号未绑定微信,无法完成支付',
|
||||
confirmText: '去绑定'
|
||||
})
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({
|
||||
url: '/pages/user_set/user_set'
|
||||
})
|
||||
}
|
||||
return Promise.reject()
|
||||
}
|
||||
}
|
||||
|
||||
// 调用预支付
|
||||
const prepayTask = async () => {
|
||||
uni.showLoading({
|
||||
title: '正在支付中'
|
||||
})
|
||||
const data = await prepay({
|
||||
order_id: props.orderId,
|
||||
from: props.from,
|
||||
pay_way: payWay.value,
|
||||
redirect: props.redirect
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
//拉起支付
|
||||
const payTask = async (data: any) => {
|
||||
try {
|
||||
if (data.pay_way === PayWayEnum.BALANCE) {
|
||||
return PayStatusEnum.SUCCESS
|
||||
}
|
||||
const res = await pay.payment(data.pay_way, data.config)
|
||||
return res
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
return series(checkIsBindWx, prepayTask, payTask)
|
||||
})()
|
||||
const { isLock, lockFn: handlePay } = useLockFn(async () => {
|
||||
try {
|
||||
const res: PayStatusEnum = await payment()
|
||||
handlePayResult(res)
|
||||
uni.hideLoading()
|
||||
} catch (error) {
|
||||
uni.hideLoading()
|
||||
console.log(error)
|
||||
}
|
||||
})
|
||||
|
||||
const handlePayResult = (status: PayStatusEnum) => {
|
||||
switch (status) {
|
||||
case PayStatusEnum.SUCCESS:
|
||||
emit('success')
|
||||
break
|
||||
case PayStatusEnum.FAIL:
|
||||
emit('fail')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const queryPayResult = async (confirm = true) => {
|
||||
const res = await getPayResult({
|
||||
order_id: props.orderId,
|
||||
from: props.from
|
||||
})
|
||||
|
||||
if (res.pay_status === 0) {
|
||||
if (confirm == true) {
|
||||
uni.$u.toast('您的订单还未支付,请重新支付')
|
||||
}
|
||||
showPay.value = true
|
||||
handlePayResult(PayStatusEnum.FAIL)
|
||||
} else {
|
||||
if (confirm == false) {
|
||||
uni.$u.toast('您的订单已经支付,请勿重新支付')
|
||||
}
|
||||
handlePayResult(PayStatusEnum.SUCCESS)
|
||||
}
|
||||
showCheckPay.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(value) => {
|
||||
if (value) {
|
||||
if (!props.orderId) {
|
||||
popupStatus.value = PageStatusEnum.ERROR
|
||||
return
|
||||
}
|
||||
getPayData()
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.payway-lists {
|
||||
.payway-item {
|
||||
border-bottom: 1px solid;
|
||||
@apply border-page;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<view class="price-container">
|
||||
<view
|
||||
:class="['price-wrap', { 'price-wrap--disabled': lineThrough }]"
|
||||
:style="{ color: color }"
|
||||
>
|
||||
<!-- Prefix -->
|
||||
<view class="fix-pre" :style="{ fontSize: minorSize }">
|
||||
<slot name="prefix">{{ prefix }}</slot>
|
||||
</view>
|
||||
|
||||
<!-- Content -->
|
||||
<view :style="{ 'font-weight': fontWeight }">
|
||||
<!-- Integer -->
|
||||
<text :style="{ fontSize: mainSize }">{{ integer }}</text>
|
||||
<!-- Decimals -->
|
||||
<text :style="{ fontSize: minorSize }">{{ decimals }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Suffix -->
|
||||
<view class="fix-suf" :style="{ fontSize: minorSize }">
|
||||
<slot name="suffix">{{ suffix }}</slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* @description 价格展示,适用于有前后缀,小数样式不一
|
||||
* @property {String|Number} content 价格 (必填项)
|
||||
* @property {Number} prec 小数位 (默认: 2)
|
||||
* @property {Boolean} autoPrec 自动小数位【注:以prec为最大小数位】 (默认: true)
|
||||
* @property {String} color 颜色 (默认: 'unset')
|
||||
* @property {String} mainSize 主要内容字体大小 (默认: 46rpx)
|
||||
* @property {String} minorSize 主要内容字体大小 (默认: 32rpx)
|
||||
* @property {Boolean} lineThrough 贯穿线 (默认: false)
|
||||
* @property {String|Number} fontWeight 字重 (默认: normal)
|
||||
* @property {String} prefix 前缀 (默认: ¥)
|
||||
* @property {String} suffix 后缀
|
||||
* @example <price content="100" suffix="\/元" />
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { formatPrice } from '@/utils/util'
|
||||
|
||||
/** Props Start **/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string | number // 标题
|
||||
prec?: number // 小数数量
|
||||
autoPrec?: boolean // 动态小数
|
||||
color?: string // 颜色
|
||||
mainSize?: string // 主要内容字体大小
|
||||
minorSize?: string // 次要内容字体大小
|
||||
lineThrough?: boolean // 贯穿线
|
||||
fontWeight?: string // 字重
|
||||
prefix?: string // 前缀
|
||||
suffix?: string // 后缀
|
||||
}>(),
|
||||
{
|
||||
content: '',
|
||||
prec: 2,
|
||||
autoPrec: true,
|
||||
color: '#FA8919',
|
||||
mainSize: '36rpx',
|
||||
minorSize: '28rpx',
|
||||
lineThrough: false,
|
||||
fontWeight: 'normal',
|
||||
prefix: '¥',
|
||||
suffix: ''
|
||||
}
|
||||
)
|
||||
/** Props End **/
|
||||
|
||||
/** Computed Start **/
|
||||
/**
|
||||
* @description 金额主体部分
|
||||
*/
|
||||
const integer = computed(() => {
|
||||
return formatPrice({
|
||||
price: props.content,
|
||||
take: 'int'
|
||||
})
|
||||
})
|
||||
/**
|
||||
* @description 金额小数部分
|
||||
*/
|
||||
const decimals = computed(() => {
|
||||
let decimals = formatPrice({
|
||||
price: props.content,
|
||||
take: 'dec',
|
||||
prec: props.prec
|
||||
})
|
||||
// 小数余十不能是 .10||.20||.30以此类推,
|
||||
decimals = decimals % 10 == 0 ? decimals.substr(0, decimals.length - 1) : decimals
|
||||
return props.autoPrec ? (decimals * 1 ? '.' + decimals : '') : props.prec ? '.' + decimals : ''
|
||||
})
|
||||
/** Computed End **/
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.price-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.price-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
&--disabled {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
display: block;
|
||||
content: '';
|
||||
height: 0.05em;
|
||||
background-color: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<u-popup v-model="innerShow" mode="center" round="16" @close="emit('update:show', false)">
|
||||
<view class="share-popup">
|
||||
<view class="share-popup__header">
|
||||
<text class="share-popup__title">分享给朋友</text>
|
||||
<view class="share-popup__close" @tap="emit('update:show', false)">
|
||||
<u-icon name="close" size="20" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="share-popup__placeholder">
|
||||
<text class="share-popup__placeholder-text">生成中...</text>
|
||||
</view>
|
||||
<view v-else-if="posterUrl" class="share-popup__poster">
|
||||
<image :src="posterUrl" class="share-popup__poster-img" mode="widthFix" />
|
||||
</view>
|
||||
<view v-else-if="qrcodeUrl" class="share-popup__card">
|
||||
<view class="share-popup__card-header">
|
||||
<text class="share-popup__card-appname">{{ posterTitle }}</text>
|
||||
<text v-if="sharerNickname" class="share-popup__card-sharer">分享自 {{ sharerNickname }}</text>
|
||||
</view>
|
||||
<view class="share-popup__card-qr-wrap">
|
||||
<image :src="qrcodeUrl" class="share-popup__card-qr" mode="widthFix" />
|
||||
</view>
|
||||
<text v-if="posterPageTitle" class="share-popup__card-title">{{ posterPageTitle }}</text>
|
||||
</view>
|
||||
<view v-else class="share-popup__placeholder">
|
||||
<text class="share-popup__placeholder-text">暂无图片</text>
|
||||
</view>
|
||||
|
||||
<view class="share-popup__code" v-if="shareCode">
|
||||
<text class="share-popup__code-label">分享码:</text>
|
||||
<text class="share-popup__code-value">{{ shareCode }}</text>
|
||||
<text class="share-popup__code-copy" @tap="copyCode">复制</text>
|
||||
</view>
|
||||
|
||||
<view class="share-popup__actions">
|
||||
<view class="share-popup__btn share-popup__btn--save" @tap="saveImage">
|
||||
<u-icon name="download" size="18" color="#fff" />
|
||||
<text>保存图片</text>
|
||||
</view>
|
||||
<view class="share-popup__btn share-popup__btn--copy" @tap="copyLink">
|
||||
<u-icon name="link" size="18" color="#fff" />
|
||||
<text>复制链接</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { getShareInfo, createShortLink } from '@/api/share'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
/** 页面类型: match | news | post | crypto | invite ... */
|
||||
pageType: string
|
||||
/** 页面ID(可选,invite等类型无需传) */
|
||||
pageId?: number | string
|
||||
/** 目标页面路径(可选,如 /pages/index/index) */
|
||||
path?: string
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 页面描述(可选) */
|
||||
description?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
|
||||
const innerShow = ref(false)
|
||||
const shortUrl = ref('')
|
||||
const posterUrl = ref('')
|
||||
const qrcodeUrl = ref('')
|
||||
const shareCode = ref('')
|
||||
const posterTitle = ref('')
|
||||
const posterPageTitle = ref('')
|
||||
const sharerNickname = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const init = async () => {
|
||||
loading.value = true
|
||||
posterUrl.value = ''
|
||||
qrcodeUrl.value = ''
|
||||
shortUrl.value = ''
|
||||
shareCode.value = ''
|
||||
posterTitle.value = ''
|
||||
posterPageTitle.value = ''
|
||||
sharerNickname.value = ''
|
||||
try {
|
||||
const info = await getShareInfo()
|
||||
const data: any = {
|
||||
page_type: props.pageType,
|
||||
invite_code: info.invite_code || '',
|
||||
path: props.path || '',
|
||||
title: props.title || '',
|
||||
description: props.description || ''
|
||||
}
|
||||
if (props.pageId) {
|
||||
data.page_id = props.pageId
|
||||
}
|
||||
const res: any = await createShortLink(data)
|
||||
shortUrl.value = res.short_url || ''
|
||||
shareCode.value = res.code || ''
|
||||
posterUrl.value = res.poster_url || (res.poster && res.poster.poster_url) || ''
|
||||
qrcodeUrl.value = res.qrcode_url || ''
|
||||
|
||||
const poster = res.poster || {}
|
||||
posterTitle.value = poster.poster_title || ''
|
||||
posterPageTitle.value = poster.title || props.title || ''
|
||||
sharerNickname.value = (poster.sharer && poster.sharer.nickname) || ''
|
||||
} catch (e) {
|
||||
console.error('生成分享图失败', e)
|
||||
uni.showToast({ title: '生成失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val: boolean) => {
|
||||
innerShow.value = val
|
||||
if (val) {
|
||||
init()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(innerShow, (val: boolean) => {
|
||||
if (!val && props.show) {
|
||||
emit('update:show', false)
|
||||
}
|
||||
})
|
||||
|
||||
const saveImage = () => {
|
||||
const targetUrl = posterUrl.value || qrcodeUrl.value
|
||||
if (!targetUrl) {
|
||||
uni.showToast({ title: '图片生成中...', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
const a = document.createElement('a')
|
||||
a.href = targetUrl
|
||||
a.download = `share_${props.pageType}_${props.pageId || ''}.png`
|
||||
a.target = '_blank'
|
||||
a.click()
|
||||
uni.showToast({ title: '已下载', icon: 'success' })
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
uni.showLoading({ title: '保存中...' })
|
||||
uni.downloadFile({
|
||||
url: targetUrl,
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '已保存到相册', icon: 'success' })
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.hideLoading()
|
||||
if (err.errMsg && err.errMsg.includes('auth')) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '需要相册权限才能保存图片,请在系统设置中开启',
|
||||
showCancel: false
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
const copyLink = () => {
|
||||
if (!shortUrl.value) {
|
||||
uni.showToast({ title: '链接生成中...', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: shortUrl.value,
|
||||
success: () => {
|
||||
uni.showToast({ title: '链接已复制', icon: 'success' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const copyCode = () => {
|
||||
if (!shareCode.value) {
|
||||
uni.showToast({ title: '分享码生成中...', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: shareCode.value,
|
||||
success: () => {
|
||||
uni.showToast({ title: '分享码已复制', icon: 'success' })
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.share-popup {
|
||||
width: 600rpx;
|
||||
padding: 40rpx;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&__close {
|
||||
padding: 10rpx;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
min-height: 400rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f6f7f9;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
&__placeholder-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__poster {
|
||||
width: 100%;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
&__poster-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__card {
|
||||
width: 100%;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
margin-bottom: 30rpx;
|
||||
padding: 40rpx 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
&__card-appname {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__card-sharer {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__card-qr-wrap {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
&__card-qr {
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__card-title {
|
||||
font-size: 26rpx;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&__code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #f6f7f9;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 10rpx;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__code-label {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__code-value {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
&__code-copy {
|
||||
font-size: 22rpx;
|
||||
color: #667eea;
|
||||
padding: 4rpx 12rpx;
|
||||
border: 1rpx solid #667eea;
|
||||
border-radius: 8rpx;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
|
||||
&--save {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
&--copy {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<view
|
||||
:class="{ active, inactive: !active, tab: true }"
|
||||
:style="shouldShow ? '' : 'display: none;'"
|
||||
>
|
||||
<slot v-if="shouldRender"></slot>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, provide, inject, watch, computed, onMounted, getCurrentInstance } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
dot?: boolean | string
|
||||
name?: boolean | string
|
||||
info?: any
|
||||
}>(),
|
||||
{
|
||||
dot: false,
|
||||
name: ''
|
||||
}
|
||||
)
|
||||
|
||||
const active = ref<boolean>(false)
|
||||
const shouldShow = ref<boolean>(false)
|
||||
const shouldRender = ref<boolean>(false)
|
||||
const inited = ref(undefined)
|
||||
|
||||
const updateTabs: any = inject('updateTabs')
|
||||
const handleChange: any = inject('handleChange')
|
||||
|
||||
const updateRender = (value) => {
|
||||
inited.value = inited.value || value
|
||||
active.value = value
|
||||
shouldRender.value = inited.value!
|
||||
shouldShow.value = value
|
||||
}
|
||||
const update = () => {
|
||||
if (updateTabs) {
|
||||
updateTabs()
|
||||
}
|
||||
}
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
console.log(instance)
|
||||
handleChange(instance?.props, updateRender)
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
})
|
||||
|
||||
const changeData = computed(() => {
|
||||
const { dot, info } = props
|
||||
return {
|
||||
dot,
|
||||
info
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => changeData.value,
|
||||
() => {
|
||||
update()
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => props.name,
|
||||
(val) => {
|
||||
update()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tab.active {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tab.inactive {
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<view>
|
||||
<UTabbar v-if="showTabbar" v-model="current" v-bind="tabbarStyle" :list="tabbarList" :mid-button="hasMidButton"
|
||||
@change="handleChange" :hide-tab-bar="hideNativeTabbar"></UTabbar>
|
||||
<AppLoading />
|
||||
<GlobalPopup />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppLoading from '@/components/app-loading/app-loading.vue'
|
||||
import GlobalPopup from '@/components/global-popup/global-popup.vue'
|
||||
const MATCH_SHELL_VIEW_KEY = 'match_shell_view'
|
||||
import UTabbar from '@/uni_modules/vk-uview-ui/components/u-tabbar/u-tabbar.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
forceShow?: boolean
|
||||
activePagePath?: string
|
||||
}>(), {
|
||||
forceShow: false,
|
||||
activePagePath: ''
|
||||
})
|
||||
|
||||
const current = ref(0)
|
||||
const appStore = useAppStore()
|
||||
const showTabbar = ref(false)
|
||||
const hideNativeTabbar = ref(false)
|
||||
const nativeTabbar = [
|
||||
'/pages/index/index',
|
||||
'/pages/match/match',
|
||||
'/pages/crypto/crypto',
|
||||
'/pages/lottery_analysis/lottery_analysis',
|
||||
'/pages/empty/empty',
|
||||
'/pages/user/user'
|
||||
]
|
||||
const tabbarRouteAliasMap: Record<string, string> = {
|
||||
'/pages/match/match': '/packages_match/pages/worldcup',
|
||||
'/pages/lottery_analysis/lottery_analysis': '/packages_lottery/pages/index',
|
||||
'/pages/empty/empty': '/packages_community/pages/index'
|
||||
}
|
||||
const tabbarList = computed(() => {
|
||||
return appStore.getTabbarConfig
|
||||
?.filter((item: any) => item.is_show == 1)
|
||||
.map((item: any) => {
|
||||
const link = typeof item.link === 'string' ? JSON.parse(item.link) : item.link
|
||||
return {
|
||||
iconPath: item.unselected,
|
||||
selectedIconPath: item.selected,
|
||||
text: item.name,
|
||||
link: link,
|
||||
pagePath: link.path,
|
||||
midButton: !!link.midButton
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const hasMidButton = computed(() => {
|
||||
return tabbarList.value?.some((item: any) => item.midButton) || false
|
||||
})
|
||||
|
||||
const getPageRoute = (page: any) => {
|
||||
let route = page.route || ''
|
||||
// H5下route可能带base前缀,需要去除
|
||||
// #ifdef H5
|
||||
route = route.replace(/^\//, '')
|
||||
if (route.startsWith('mobile/')) {
|
||||
route = route.replace('mobile/', '')
|
||||
}
|
||||
// #endif
|
||||
return '/' + route
|
||||
}
|
||||
|
||||
const normalizeTabbarRoute = (route?: string) => {
|
||||
if (!route) {
|
||||
return ''
|
||||
}
|
||||
return tabbarRouteAliasMap[route] || route
|
||||
}
|
||||
|
||||
const isSameTabbarRoute = (left?: string, right?: string) => {
|
||||
return normalizeTabbarRoute(left) === normalizeTabbarRoute(right)
|
||||
}
|
||||
|
||||
const updateTabbarState = () => {
|
||||
hideNativeTabbar.value = false
|
||||
if (props.forceShow && props.activePagePath) {
|
||||
const forceRoute = props.activePagePath
|
||||
const idx = tabbarList.value?.findIndex((item: any) => isSameTabbarRoute(item.pagePath, forceRoute)) ?? -1
|
||||
showTabbar.value = idx >= 0
|
||||
if (idx >= 0) {
|
||||
current.value = idx
|
||||
}
|
||||
return
|
||||
}
|
||||
const currentPages = getCurrentPages()
|
||||
if (!currentPages.length) {
|
||||
return
|
||||
}
|
||||
const currentPage = currentPages[currentPages.length - 1]
|
||||
const rawCurrentRoute = getPageRoute(currentPage)
|
||||
const currentRoute = normalizeTabbarRoute(rawCurrentRoute)
|
||||
hideNativeTabbar.value = nativeTabbar.includes(rawCurrentRoute)
|
||||
const idx = tabbarList.value?.findIndex((item: any) => {
|
||||
return isSameTabbarRoute(item.pagePath, currentRoute)
|
||||
})
|
||||
showTabbar.value = idx >= 0
|
||||
if (idx >= 0) {
|
||||
current.value = idx
|
||||
}
|
||||
}
|
||||
|
||||
const syncCurrent = () => {
|
||||
updateTabbarState()
|
||||
setTimeout(updateTabbarState, 50)
|
||||
setTimeout(updateTabbarState, 180)
|
||||
setTimeout(updateTabbarState, 360)
|
||||
}
|
||||
|
||||
onMounted(syncCurrent)
|
||||
onShow(syncCurrent)
|
||||
|
||||
watch(() => appStore.tabbarTick, syncCurrent)
|
||||
|
||||
// tabbarList 变化(API 加载完成)时主动 bump tick 触发重算
|
||||
watch(() => tabbarList.value, () => {
|
||||
appStore.tabbarTick++
|
||||
syncCurrent()
|
||||
}, { deep: true })
|
||||
|
||||
const tabbarStyle = computed(() => ({
|
||||
activeColor: appStore.getStyleConfig?.selected_color || '#0b55eb',
|
||||
inactiveColor: appStore.getStyleConfig?.default_color || '#999999'
|
||||
}))
|
||||
const handleChange = (index: number) => {
|
||||
const selectTab = tabbarList.value[index]
|
||||
const targetRoute = normalizeTabbarRoute(selectTab?.pagePath)
|
||||
console.log('[Tabbar] 切换tab:', index, selectTab?.text, selectTab?.pagePath, '=>', targetRoute)
|
||||
if (!targetRoute) {
|
||||
return
|
||||
}
|
||||
// 防止重复跳转当前页面
|
||||
const currentPages = getCurrentPages()
|
||||
if (currentPages.length) {
|
||||
const curRoute = getPageRoute(currentPages[currentPages.length - 1])
|
||||
if (isSameTabbarRoute(curRoute, targetRoute)) return
|
||||
}
|
||||
if (selectTab.midButton) {
|
||||
uni.navigateTo({ url: targetRoute })
|
||||
return
|
||||
}
|
||||
if (targetRoute === '/packages_match/pages/worldcup') {
|
||||
uni.setStorageSync(MATCH_SHELL_VIEW_KEY, 'worldcup')
|
||||
}
|
||||
if (nativeTabbar.includes(targetRoute)) {
|
||||
uni.switchTab({ url: targetRoute })
|
||||
} else {
|
||||
uni.reLaunch({ url: targetRoute })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
/**
|
||||
* @description 触碰屏幕钩子函数
|
||||
* @return { Function } 暴露钩子
|
||||
*/
|
||||
export function useTouch() {
|
||||
// 最小移动距离
|
||||
const MIN_DISTANCE = 10
|
||||
|
||||
const touch = reactive<any>({
|
||||
direction: '',
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0
|
||||
})
|
||||
|
||||
/**
|
||||
* @description 计算距离
|
||||
* @return { string } 空字符串
|
||||
*/
|
||||
const getDirection = (x: number, y: number) => {
|
||||
if (x > y && x > MIN_DISTANCE) {
|
||||
return 'horizontal'
|
||||
}
|
||||
if (y > x && y > MIN_DISTANCE) {
|
||||
return 'vertical'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 重置参数
|
||||
*/
|
||||
const resetTouchStatus = () => {
|
||||
touch.direction = ''
|
||||
touch.deltaX = 0
|
||||
touch.deltaY = 0
|
||||
touch.offsetX = 0
|
||||
touch.offsetY = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 触发
|
||||
*/
|
||||
const touchStart = (event: any) => {
|
||||
resetTouchStatus()
|
||||
const events = event.touches[0]
|
||||
touch.startX = events.clientX
|
||||
touch.startY = events.clientY
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 移动
|
||||
*/
|
||||
const touchMove = (event: any) => {
|
||||
const events = event.touches[0]
|
||||
touch.deltaX = events.clientX - touch.startX
|
||||
touch.deltaY = events.clientY - touch.startY
|
||||
touch.offsetX = Math.abs(touch.deltaX)
|
||||
touch.offsetY = Math.abs(touch.deltaY)
|
||||
touch.direction = touch.direction || getDirection(touch.offsetX, touch.offsetY)
|
||||
}
|
||||
|
||||
return {
|
||||
touch,
|
||||
resetTouchStatus,
|
||||
touchStart,
|
||||
touchMove
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<view class="tabs">
|
||||
<u-sticky :enable="isFixed" :bg-color="stickyBgColor" :offset-top="top" :h5-nav-height="0">
|
||||
<view
|
||||
:id="id"
|
||||
:style="{
|
||||
background: bgColor
|
||||
}"
|
||||
>
|
||||
<scroll-view
|
||||
:style="{ height: height + 'rpx' }"
|
||||
scroll-x
|
||||
class="scroll-view"
|
||||
:scroll-left="scrollLeft"
|
||||
scroll-with-animation
|
||||
>
|
||||
<view class="scroll-box" :class="{ 'tabs-scorll-flex': !isScroll }">
|
||||
<view
|
||||
v-for="(item, index) in list"
|
||||
class="tab-item line1"
|
||||
:id="'tab-item-' + index"
|
||||
:key="index"
|
||||
@tap="clickTab(index)"
|
||||
:style="[tabItemStyle(index)]"
|
||||
>
|
||||
<u-badge
|
||||
:count="item[count] || item['dot'] || 0"
|
||||
:offset="offset"
|
||||
size="mini"
|
||||
></u-badge>
|
||||
{{ item[name] || item['name'] }}
|
||||
</view>
|
||||
<view v-if="showBar" class="tab-bar" :style="[tabBarStyle]"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</u-sticky>
|
||||
<view
|
||||
class="tab-content"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchcancel="onTouchEnd"
|
||||
@touchend="onTouchEnd"
|
||||
>
|
||||
<!-- <view class="tab-track" :class="{'tab-animated': animated}" :style="[trackStyle]"> -->
|
||||
<view>
|
||||
<slot></slot>
|
||||
</view>
|
||||
<!-- </view> -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getRect } from '@/utils/util'
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
computed,
|
||||
watch,
|
||||
provide,
|
||||
nextTick,
|
||||
onMounted,
|
||||
getCurrentInstance
|
||||
} from 'vue'
|
||||
import { useTouch } from './hooks/useTouch'
|
||||
|
||||
// Touch 钩子
|
||||
const { touch, resetTouchStatus, touchStart, touchMove } = useTouch()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'change', value: number): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isScroll?: boolean // 导航菜单是否需要滚动,如只有2或者3个的时候,就不需要滚动了,此时使用flex平分tab的宽度
|
||||
current?: number | string // 当前活动tab的索引
|
||||
height?: number | string // 导航栏的高度和行高
|
||||
fontSize?: number | string // 字体大小
|
||||
duration?: number | string // 过渡动画时长, 单位ms
|
||||
activeColor?: number | string // 选中项的主题颜色
|
||||
inactiveColor?: number | string // 未选中项的颜色
|
||||
barWidth?: number | string // 菜单底部移动的bar的宽度,单位rpx
|
||||
barHeight?: number // 移动bar的高度
|
||||
gutter?: number | string // 单个tab的左或有内边距(左右相同)
|
||||
bgColor?: number | string // 导航栏的背景颜色
|
||||
name?: string // 读取传入的数组对象的属性(tab名称)
|
||||
count?: string // 读取传入的数组对象的属性(徽标数)
|
||||
offset?: number[] // 徽标数位置偏移
|
||||
bold?: boolean // 活动tab字体是否加粗
|
||||
activeItemStyle?: any // 当前活动tab item的样式
|
||||
showBar?: boolean // 是否显示底部的滑块
|
||||
barStyle?: any // 底部滑块的自定义样式
|
||||
itemWidth?: string // 标签的宽度
|
||||
isFixed?: boolean // 吸顶是否固定
|
||||
top?: number | string // 吸顶顶部距离
|
||||
stickyBgColor?: string // 吸顶颜色
|
||||
|
||||
swipeable?: boolean // 是否允许滑动切换
|
||||
// animated: boolean // 切换动画
|
||||
}>(),
|
||||
{
|
||||
isScroll: true,
|
||||
current: 0,
|
||||
height: 80,
|
||||
fontSize: 28,
|
||||
duration: 0.3,
|
||||
activeColor: 'var(--color-primary)',
|
||||
inactiveColor: '#333',
|
||||
barWidth: 40,
|
||||
barHeight: 4,
|
||||
gutter: 30,
|
||||
bgColor: '#FFFFFF',
|
||||
name: 'name',
|
||||
count: 'count',
|
||||
offset: [5, 20],
|
||||
bold: true,
|
||||
activeItemStyle: {},
|
||||
showBar: true,
|
||||
barStyle: {},
|
||||
itemWidth: 'auto',
|
||||
isFixed: false,
|
||||
top: 0,
|
||||
stickyBgColor: '#FFFFFF',
|
||||
|
||||
swipeable: true
|
||||
// animated: true
|
||||
}
|
||||
)
|
||||
|
||||
const list = ref<any>([])
|
||||
const childrens = ref<any>([])
|
||||
const scrollLeft = ref<number>(0) // 滚动scroll-view的左边滚动距离
|
||||
const tabQueryInfo = ref<any>([]) // 存放对tab菜单查询后的节点信息
|
||||
const componentWidth = ref<number>(0) // 屏幕宽度,单位为px
|
||||
const scrollBarLeft = ref<number>(0) // 移动bar需要通过translateX()移动的距离
|
||||
const parentLeft = ref<number>(0) // 父元素(tabs组件)到屏幕左边的距离
|
||||
const id = ref<string>('cu-tab') // id值
|
||||
const currentIndex = ref<any>(props.current)
|
||||
const barFirstTimeMove = ref<boolean>(true) // 滑块第一次移动时(页面刚生成时),无需动画,否则给人怪异的感觉
|
||||
const swiping = ref<boolean>(false)
|
||||
|
||||
//@ts-ignore
|
||||
const ctx = getCurrentInstance()
|
||||
|
||||
// 监听tab的变化,重新计算tab菜单的布局信息,因为实际使用中菜单可能是通过
|
||||
// 后台获取的(如新闻app顶部的菜单),获取返回需要一定时间,所以list变化时,重新获取布局信息
|
||||
watch(
|
||||
() => list.value,
|
||||
async (n, o) => {
|
||||
// list变动时,重制内部索引,否则可能导致超出数组边界的情况
|
||||
if (!barFirstTimeMove.value && n.length !== o.length) {
|
||||
currentIndex.value = 0
|
||||
}
|
||||
// 用$nextTick等待视图更新完毕后再计算tab的局部信息,否则可能因为tab还没生成就获取,就会有问题
|
||||
await nextTick()
|
||||
init()
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => props.current,
|
||||
(nVal, oVal) => {
|
||||
// 视图更新后再执行移动操作、
|
||||
nextTick(() => {
|
||||
currentIndex.value = nVal
|
||||
scrollByIndex()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 移动bar的样式
|
||||
const tabBarStyle = computed(() => {
|
||||
const style = {
|
||||
width: props.barWidth + 'rpx',
|
||||
transform: `translate(${scrollBarLeft.value}px, -100%)`,
|
||||
// 滑块在页面渲染后第一次滑动时,无需动画效果
|
||||
'transition-duration': `${barFirstTimeMove.value ? 0 : props.duration}s`,
|
||||
'background-color': props.activeColor,
|
||||
height: props.barHeight + 'rpx',
|
||||
opacity: barFirstTimeMove.value ? 0 : 1,
|
||||
// 设置一个很大的值,它会自动取能用的最大值,不用高度的一半,是因为高度可能是单数,会有小数出现
|
||||
'border-radius': `${props.barHeight / 2}px`
|
||||
}
|
||||
Object.assign(style, props.barStyle)
|
||||
return style
|
||||
})
|
||||
// tab的样式
|
||||
const tabItemStyle = computed(() => {
|
||||
return (index) => {
|
||||
let style: any = {
|
||||
height: props.height + 'rpx',
|
||||
'line-height': props.height + 'rpx',
|
||||
'font-size': props.fontSize + 'rpx',
|
||||
padding: props.isScroll ? `0 ${props.gutter}rpx` : '',
|
||||
flex: props.isScroll ? 'auto' : '1',
|
||||
width: `${props.itemWidth}rpx`
|
||||
}
|
||||
// 字体加粗
|
||||
if (index == currentIndex.value && props.bold) style.fontWeight = 'bold'
|
||||
if (index == currentIndex.value) {
|
||||
style.color = props.activeColor
|
||||
// 给选中的tab item添加外部自定义的样式
|
||||
style = Object.assign(style, props.activeItemStyle)
|
||||
} else {
|
||||
style.color = props.inactiveColor
|
||||
}
|
||||
return style
|
||||
}
|
||||
})
|
||||
|
||||
// const trackStyle = computed(() => {
|
||||
// if (!props.animated) return ''
|
||||
// return {
|
||||
// left: -100 * currentIndex.value + '%',
|
||||
// 'transition-duration': props.duration + 's',
|
||||
// '-webkit-transition-duration': props.duration + 's',
|
||||
// }
|
||||
// })
|
||||
|
||||
const updateTabs = () => {
|
||||
list.value = childrens.value.map((item) => {
|
||||
const { name, dot, active, inited } = item.event
|
||||
const { updateRender } = item
|
||||
return {
|
||||
name,
|
||||
dot,
|
||||
active,
|
||||
inited,
|
||||
updateRender
|
||||
}
|
||||
})
|
||||
// nextTick(() => {
|
||||
// init()
|
||||
// })
|
||||
}
|
||||
|
||||
// 设置一个init方法,方便多处调用
|
||||
const init = async () => {
|
||||
// 获取tabs组件的尺寸信息
|
||||
const tabRect = await getRect('#' + id.value, false, ctx)
|
||||
// tabs组件距离屏幕左边的宽度
|
||||
parentLeft.value = tabRect.left
|
||||
// tabs组件的宽度
|
||||
componentWidth.value = tabRect.width
|
||||
getTabRect()
|
||||
}
|
||||
|
||||
// 点击某一个tab菜单
|
||||
const clickTab = (index) => {
|
||||
// 点击当前活动tab,不触发事件
|
||||
if (index == currentIndex.value) return
|
||||
nextTick(() => {
|
||||
currentIndex.value = index
|
||||
scrollByIndex()
|
||||
})
|
||||
// 发送事件给父组件
|
||||
emit('change', index)
|
||||
}
|
||||
|
||||
// 查询tab的布局信息
|
||||
const getTabRect = () => {
|
||||
// 创建节点查询
|
||||
const query: any = uni.createSelectorQuery().in(ctx)
|
||||
// 历遍所有tab,这里是执行了查询,最终使用exec()会一次性返回查询的数组结果
|
||||
for (let i = 0; i < list.value.length; i++) {
|
||||
// 只要size和rect两个参数
|
||||
query.select(`#tab-item-${i}`).fields({
|
||||
size: true,
|
||||
rect: true
|
||||
})
|
||||
}
|
||||
// 执行查询,一次性获取多个结果
|
||||
query.exec((res) => {
|
||||
tabQueryInfo.value = res
|
||||
// 初始化滚动条和移动bar的位置
|
||||
scrollByIndex()
|
||||
})
|
||||
}
|
||||
|
||||
// 滚动scroll-view,让活动的tab处于屏幕的中间位置
|
||||
const scrollByIndex = () => {
|
||||
// 当前活动tab的布局信息,有tab菜单的width和left(为元素左边界到父元素左边界的距离)等信息
|
||||
const tabInfo = tabQueryInfo.value[currentIndex.value]
|
||||
if (!tabInfo) return
|
||||
// 活动tab的宽度
|
||||
const tabWidth = tabInfo.width
|
||||
// 活动item的左边到tabs组件左边的距离,用item的left减去tabs的left
|
||||
const offsetLeft = tabInfo.left - parentLeft.value
|
||||
// 将活动的tabs-item移动到屏幕正中间,实际上是对scroll-view的移动
|
||||
const scrollLefts = offsetLeft - (componentWidth.value - tabWidth) / 2
|
||||
scrollLeft.value = scrollLefts < 0 ? 0 : scrollLefts
|
||||
// 当前活动item的中点点到左边的距离减去滑块宽度的一半,即可得到滑块所需的移动距离
|
||||
const left = tabInfo.left + tabInfo.width / 2 - parentLeft.value
|
||||
// 计算当前活跃item到组件左边的距离
|
||||
scrollBarLeft.value = left - uni.upx2px(props.barWidth) / 2
|
||||
// 第一次移动滑块的时候,barFirstTimeMove为true,放到延时中将其设置false
|
||||
// 延时是因为scrollBarLeft作用于computed计算时,需要一个过程需,否则导致出错
|
||||
if (barFirstTimeMove.value == true) {
|
||||
setTimeout(() => {
|
||||
barFirstTimeMove.value = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 更新子组件的显示
|
||||
childrens.value.forEach((item, ind) => {
|
||||
const active = ind === currentIndex.value
|
||||
if (active !== item.event.active || !item.event.inited) {
|
||||
item.updateRender(active)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 子组件调用此函数而产生的事件通信
|
||||
const handleChange = (event, updateRender) => {
|
||||
childrens.value.push({ event: event, updateRender })
|
||||
}
|
||||
// 手指触摸
|
||||
const onTouchStart = (event) => {
|
||||
if (!props.swipeable) return
|
||||
swiping.value = true
|
||||
touchStart(event)
|
||||
}
|
||||
// 手指滑动
|
||||
const onTouchMove = (event) => {
|
||||
if (!props.swipeable || !swiping.value) return
|
||||
touchMove(event)
|
||||
}
|
||||
// 手指滑动结束
|
||||
const onTouchEnd = () => {
|
||||
if (!props.swipeable || !swiping.value) return
|
||||
const minSwipeDistance = 50
|
||||
if (touch.direction === 'horizontal' && touch.offsetX >= minSwipeDistance) {
|
||||
let index,
|
||||
len = list.value.length,
|
||||
curIndex = currentIndex.value
|
||||
if (touch.deltaX <= 0) {
|
||||
curIndex >= len - 1 ? (index = 0) : (index = curIndex + 1)
|
||||
} else {
|
||||
curIndex <= 0 ? (index = len - 1) : (index = curIndex - 1)
|
||||
}
|
||||
nextTick(() => {
|
||||
currentIndex.value = index
|
||||
scrollByIndex()
|
||||
})
|
||||
// 发送事件给父组件
|
||||
emit('change', index)
|
||||
}
|
||||
swiping.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTabs()
|
||||
})
|
||||
|
||||
provide('handleChange', handleChange)
|
||||
provide('updateTabs', updateTabs)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* #ifndef APP-NVUE */
|
||||
::-webkit-scrollbar,
|
||||
::-webkit-scrollbar,
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* #endif */
|
||||
|
||||
.scroll-box {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
/* #ifdef MP-TOUTIAO */
|
||||
white-space: nowrap;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
.tab-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* #ifdef H5 */
|
||||
// 通过样式穿透,隐藏H5下,scroll-view下的滚动条
|
||||
scroll-view ::v-deep ::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* #endif */
|
||||
|
||||
.scroll-view {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
position: relative;
|
||||
/* #ifndef APP-NVUE */
|
||||
display: inline-block;
|
||||
/* #endif */
|
||||
text-align: center;
|
||||
transition-property: background-color, color;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
position: absolute;
|
||||
bottom: 6rpx;
|
||||
}
|
||||
|
||||
.tabs-scorll-flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// .tab-content {
|
||||
// overflow: hidden;
|
||||
// .tab-track {
|
||||
// position: relative;
|
||||
// width: 100%;
|
||||
// height: 100%;
|
||||
// }
|
||||
// .tab-animated {
|
||||
// display: flex;
|
||||
// transition-property: left;
|
||||
// }
|
||||
// }
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<navigator :url="detailUrl" class="translated-article-card" :class="cardClass">
|
||||
<view class="translated-article-card__content">
|
||||
<view v-if="item.image" class="translated-article-card__media">
|
||||
<image class="translated-article-card__image" :src="item.image" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="translated-article-card__title">{{ item.title }}</view>
|
||||
<view v-if="translatedTitle" class="translated-article-card__title-translate">{{ translatedTitle }}</view>
|
||||
<view v-if="descText" class="translated-article-card__desc">{{ descText }}</view>
|
||||
<view v-if="translatedDesc" class="translated-article-card__desc-translate">{{ translatedDesc }}</view>
|
||||
</view>
|
||||
<view class="translated-article-card__footer">
|
||||
<text class="translated-article-card__author">{{ item.author || authorFallback }}</text>
|
||||
<text class="translated-article-card__meta">{{ commentText }}</text>
|
||||
<text class="translated-article-card__meta">{{ formatTime(item.create_time) }}</text>
|
||||
</view>
|
||||
</navigator>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: any
|
||||
variant?: 'feed' | 'worldcup'
|
||||
authorFallback?: string
|
||||
commentPrefix?: string
|
||||
}>(),
|
||||
{
|
||||
item: () => ({}),
|
||||
variant: 'feed',
|
||||
authorFallback: '体育小编',
|
||||
commentPrefix: ''
|
||||
}
|
||||
)
|
||||
|
||||
const stripText = (text: string) => {
|
||||
if (!text) return ''
|
||||
return String(text)
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const truncateText = (text: string, maxLength = 96) => {
|
||||
if (!text) return ''
|
||||
if (text.length <= maxLength) return text
|
||||
return `${text.slice(0, maxLength).trimEnd()}...`
|
||||
}
|
||||
|
||||
const translatedParts = computed(() => {
|
||||
const text = stripText(props.item.translated_content || props.item.worldcup_translated_content || '')
|
||||
if (!text) {
|
||||
return {
|
||||
title: '',
|
||||
desc: '',
|
||||
body: ''
|
||||
}
|
||||
}
|
||||
|
||||
const titleMatch = text.match(/(?:^|\s)(?:标题|title)[::]\s*([\s\S]*?)(?=\s*(?:摘要|正文|desc|summary|content|body)[::]|$)/i)
|
||||
const descMatch = text.match(/(?:^|\s)(?:摘要|desc|summary)[::]\s*([\s\S]*?)(?=\s*(?:正文|content|body)[::]|$)/i)
|
||||
const bodyMatch = text.match(/(?:^|\s)(?:正文|content|body)[::]\s*([\s\S]*)$/i)
|
||||
|
||||
return {
|
||||
title: stripText(props.item.translated_title || titleMatch?.[1] || ''),
|
||||
desc: stripText(props.item.translated_desc || props.item.translated_abstract || descMatch?.[1] || ''),
|
||||
body: stripText(bodyMatch?.[1] || (!titleMatch && !descMatch ? text : ''))
|
||||
}
|
||||
})
|
||||
|
||||
const cardClass = computed(() => `translated-article-card--${props.variant}`)
|
||||
|
||||
const detailUrl = computed(() => `/pages/news_detail/news_detail?id=${props.item.id}`)
|
||||
|
||||
const descText = computed(() => {
|
||||
return truncateText(stripText(props.item.desc || props.item.abstract || props.item.content || ''), props.variant === 'worldcup' ? 100 : 88)
|
||||
})
|
||||
|
||||
const translatedTitle = computed(() => {
|
||||
return truncateText(translatedParts.value.title, props.variant === 'worldcup' ? 42 : 48)
|
||||
})
|
||||
|
||||
const translatedDesc = computed(() => {
|
||||
return truncateText(translatedParts.value.desc || translatedParts.value.body, props.variant === 'worldcup' ? 78 : 126)
|
||||
})
|
||||
|
||||
const commentText = computed(() => {
|
||||
return `${props.commentPrefix}${props.item.comment_count || props.item.click || 0}评论`
|
||||
})
|
||||
|
||||
const formatTime = (t: string) => {
|
||||
if (!t) return ''
|
||||
const d = new Date(String(t).replace(/-/g, '/'))
|
||||
if (isNaN(d.getTime())) return t
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
if (props.variant === 'worldcup') {
|
||||
return `${y}-${m}-${day} ${h}:${min}`
|
||||
}
|
||||
return `${m}-${day} ${h}:${min}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.translated-article-card {
|
||||
display: block;
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
&--feed {
|
||||
padding: 24rpx 30rpx;
|
||||
}
|
||||
|
||||
&--worldcup {
|
||||
padding: 22rpx 20rpx 18rpx 0;
|
||||
border-bottom-color: #edf0f5;
|
||||
}
|
||||
|
||||
&__content {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__media {
|
||||
float: right;
|
||||
width: 260rpx;
|
||||
height: 180rpx;
|
||||
margin: 0 0 12rpx 20rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 8rpx;
|
||||
background: #e5eaf1;
|
||||
}
|
||||
|
||||
&--worldcup &__media {
|
||||
width: 232rpx;
|
||||
height: 180rpx;
|
||||
margin-left: 18rpx;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
color: #07111f;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&--worldcup &__title {
|
||||
font-size: 34rpx;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
&__title-translate {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.45;
|
||||
color: #17365f;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&--worldcup &__title-translate {
|
||||
margin-top: 10rpx;
|
||||
color: #2c3f58;
|
||||
}
|
||||
|
||||
&__desc,
|
||||
&__desc-translate {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&--worldcup &__desc,
|
||||
&--worldcup &__desc-translate {
|
||||
margin-top: 12rpx;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
color: #6f7b8d;
|
||||
}
|
||||
|
||||
&--worldcup &__desc {
|
||||
color: #0f243d;
|
||||
}
|
||||
|
||||
&__desc-translate {
|
||||
color: #17365f;
|
||||
}
|
||||
|
||||
&--worldcup &__desc-translate {
|
||||
color: #42566f;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-top: 16rpx;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&--worldcup &__footer {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
&__author,
|
||||
&__meta {
|
||||
font-size: 23rpx;
|
||||
line-height: 1.3;
|
||||
color: #9aa3af;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--worldcup &__author,
|
||||
&--worldcup &__meta {
|
||||
font-size: 22rpx;
|
||||
color: #8190a3;
|
||||
}
|
||||
|
||||
&__author {
|
||||
max-width: 160rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&--worldcup &__author {
|
||||
max-width: 220rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<view class="vip-card" @tap="goVip">
|
||||
<image v-if="bgImage" class="vip-card__bg" :src="bgImage" mode="aspectFill" />
|
||||
<view class="vip-card__content">
|
||||
<view class="vip-card__info">
|
||||
<text class="vip-card__title">{{ isVip ? vipLevelName : 'VIP会员' }}</text>
|
||||
<text class="vip-card__desc">
|
||||
{{ isVip ? '每日无限次AI深度分析报告' : '即刻享受会员专属权益' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="vip-card__right">
|
||||
<text v-if="isVip && remainDays >= 0" class="vip-card__expire">
|
||||
剩余{{ remainDays }}天
|
||||
</text>
|
||||
<text v-else class="vip-card__btn">限时特惠</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
vipInfo?: {
|
||||
is_vip?: boolean
|
||||
vip_level?: number
|
||||
vip_level_name?: string
|
||||
vip_expire_time?: string
|
||||
vip_expire_timestamp?: number
|
||||
vip_card_image?: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const isVip = computed(() => !!props.vipInfo?.is_vip)
|
||||
const vipLevelName = computed(() => props.vipInfo?.vip_level_name || '会员')
|
||||
|
||||
const bgImage = computed(() => {
|
||||
const img = props.vipInfo?.vip_card_image || ''
|
||||
if (!img) return ''
|
||||
if (img.startsWith('http')) return img
|
||||
return `${import.meta.env.VITE_APP_BASE_URL || ''}${img}`
|
||||
})
|
||||
|
||||
const remainDays = computed(() => {
|
||||
if (!props.vipInfo?.vip_expire_timestamp) return -1
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return Math.max(0, Math.ceil((props.vipInfo.vip_expire_timestamp - now) / 86400))
|
||||
})
|
||||
|
||||
function goVip() {
|
||||
uni.navigateTo({ url: '/pages/vip/vip' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vip-card {
|
||||
position: relative;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
height: 156rpx;
|
||||
/* 默认渐变背景:当后端未配置 vip_card_image 时兜底,保证卡片始终可见 */
|
||||
background: linear-gradient(135deg, #1a2647 0%, #2a3a73 55%, #3f2459 100%);
|
||||
|
||||
&__bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 28rpx;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #a9c6ef;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 22rpx;
|
||||
color: #a9c6ef;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__expire {
|
||||
font-size: 22rpx;
|
||||
color: #a9c6ef;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
padding: 0 28rpx;
|
||||
background: linear-gradient(135deg, #efc8a9, #d4a574);
|
||||
border-radius: 28rpx;
|
||||
font-size: 24rpx;
|
||||
color: #5b2f0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<view
|
||||
class="banner translate-y-0"
|
||||
:class="{ 'px-[20rpx]': !isLargeScreen }"
|
||||
v-if="content.data.length && content.enabled"
|
||||
>
|
||||
<LSwiper
|
||||
:content="content"
|
||||
:height="isLargeScreen ? '1100' : '321'"
|
||||
:circular="true"
|
||||
:effect3d="false"
|
||||
:border-radius="isLargeScreen ? '0' : '14'"
|
||||
interval="7000"
|
||||
bgColor="transparent"
|
||||
@change="handleChange"
|
||||
></LSwiper>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LSwiper from '@/components/l-swiper/l-swiper.vue'
|
||||
import {useAppStore} from "@/stores/app";
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
isLargeScreen: {
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
const {getImageUrl} = useAppStore();
|
||||
|
||||
const handleChange = (index: number) => {
|
||||
emit('change', getImageUrl(props['content'].data[index].bg))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<view class="bg-white p-[30rpx] flex text-[#101010] font-medium text-lg">
|
||||
联系我们
|
||||
</view>
|
||||
<view
|
||||
class="customer-service bg-white flex flex-col justify-center items-center mx-[36rpx] mt-[30rpx] rounded-[20rpx] px-[20rpx] pb-[100rpx]"
|
||||
>
|
||||
<view
|
||||
class="w-full border-solid border-0 border-b border-[#f5f5f5] p-[30rpx] text-center text-[#101010] text-base font-medium">
|
||||
{{ content.title }}
|
||||
</view>
|
||||
|
||||
<view class="mt-[60rpx]">
|
||||
<!-- 这样渲染是为了能在小程序中长按识别二维码 -->
|
||||
<u-parse :html="richTxt"></u-parse>
|
||||
<!-- <u-image width="200" height="200" border-radius="10rpx" :src="getImageUrl(content.qrcode)"/>-->
|
||||
</view>
|
||||
<view v-if="content.remark" class="text-sm mt-[40rpx] font-medium">{{ content.remark }}</view>
|
||||
<view v-if="content.mobile" class="text-sm mt-[24rpx] flex flex-wrap">
|
||||
<!-- #ifdef H5 -->
|
||||
<a class="ml-[10rpx] phone text-primary underline" :href="'tel:' + content.mobile">
|
||||
{{ content.mobile }}
|
||||
</a>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<view class="ml-[10rpx] phone text-primary underline" @click="handleCall">
|
||||
{{ content.mobile }}
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<view v-if="content.time" class="text-muted text-sm mt-[30rpx]">
|
||||
服务时间:{{ content.time }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {useAppStore} from '@/stores/app'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const {getImageUrl} = useAppStore()
|
||||
|
||||
const richTxt = computed(() => {
|
||||
const src = getImageUrl(props.content.qrcode)
|
||||
return `<img src="${src}" style="width: 100px;height: 100px; border-radius: 8px" />`
|
||||
})
|
||||
|
||||
const handleCall = () => {
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: String(props.content.mobile)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<view
|
||||
class="banner h-[200rpx] mx-[20rpx] mt-[20rpx] translate-y-0"
|
||||
v-if="showList.length && content.enabled"
|
||||
>
|
||||
<swiper
|
||||
class="swiper h-full"
|
||||
:indicator-dots="showList.length > 1"
|
||||
indicator-active-color="#4173ff"
|
||||
:autoplay="true"
|
||||
>
|
||||
<swiper-item
|
||||
v-for="(item, index) in showList"
|
||||
:key="index"
|
||||
@click="handleClick(item.link)"
|
||||
>
|
||||
<u-image
|
||||
mode="widthFix"
|
||||
width="100%"
|
||||
height="100%"
|
||||
:src="getImageUrl(item.image)"
|
||||
:border-radius="14"
|
||||
/>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { navigateTo } from '@/utils/util'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
const handleClick = (link: any) => {
|
||||
navigateTo(link)
|
||||
}
|
||||
const { getImageUrl } = useAppStore()
|
||||
|
||||
const showList = computed(() => {
|
||||
return props.content.data?.filter((item: any) => item.is_show == '1') || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="my-service bg-white mx-[20rpx] mt-[20rpx] rounded-lg p-[30rpx]">
|
||||
<div
|
||||
v-if="content.title"
|
||||
class="title font-medium text-lg"
|
||||
>
|
||||
<div>{{ content.title }}</div>
|
||||
</div>
|
||||
<div v-if="content.style == 1" class="grid grid-cols-4 gap-x-9 gap-y-7">
|
||||
<div
|
||||
v-for="(item, index) in showList"
|
||||
:key="index"
|
||||
class="flex flex-col items-center pt-[40rpx]"
|
||||
@click="handleClick(item.link)"
|
||||
>
|
||||
<u-image width="52" height="52" :src="getImageUrl(item.image)" alt="" />
|
||||
<div class="mt-[22rpx] text-sm">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="content.style == 2">
|
||||
<div
|
||||
v-for="(item, index) in showList"
|
||||
:key="index"
|
||||
class="flex items-center border-light border-solid border-0 border-b h-[100rpx] px-[24rpx]"
|
||||
@click="handleClick(item.link)"
|
||||
>
|
||||
<u-image width="48" height="48" :src="getImageUrl(item.image)" alt="" />
|
||||
<div class="ml-[20rpx] flex-1 text-sm">{{ item.name }}</div>
|
||||
<div class="text-muted">
|
||||
<u-icon name="arrow-right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { navigateTo } from '@/utils/util'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
const { getImageUrl } = useAppStore()
|
||||
const handleClick = (link: any) => {
|
||||
navigateTo(link)
|
||||
}
|
||||
|
||||
const showList = computed(() => {
|
||||
return props.content.data?.filter((item: any) => item.is_show == '1') || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="relative mx-[20rpx] mt-[20rpx]">
|
||||
<swiper
|
||||
class="py-[20rpx] bg-white rounded-lg"
|
||||
:style="{
|
||||
height: navList[0].length > content.per_line ? '288rpx' : '132rpx'
|
||||
}"
|
||||
:autoplay="false"
|
||||
:indicator-dots="false"
|
||||
@change="swiperChange"
|
||||
>
|
||||
<swiper-item v-for="(sItem, sIndex) in navList" :key="sIndex">
|
||||
<view class="nav" v-if="navList.length && content.enabled">
|
||||
<view
|
||||
class="grid grid-rows-auto gap-y-3 w-full"
|
||||
:style="{ 'grid-template-columns': `repeat(${content.per_line}, 1fr)` }"
|
||||
>
|
||||
<view
|
||||
v-for="(item, index) in sItem"
|
||||
:key="index"
|
||||
class="flex flex-col items-center"
|
||||
@click="handleClick(item.link)"
|
||||
>
|
||||
<u-image width="82" height="82" :src="getImageUrl(item.image)" alt=""/>
|
||||
<view class="mt-[14rpx] text-xs">{{ item.name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, computed} from 'vue'
|
||||
import {useAppStore} from '@/stores/app'
|
||||
import {navigateTo, sliceArray} from '@/utils/util'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const {getImageUrl} = useAppStore()
|
||||
const swiperCurrent = ref<number>(0)
|
||||
const navList = ref<Record<string, any>>([])
|
||||
|
||||
const pagesNum = computed<number>(() => {
|
||||
return props.content.per_line * props.content.show_line
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.content.data,
|
||||
(val) => {
|
||||
const num = props.content.style === 1 ? val.length : pagesNum.value
|
||||
navList.value = sliceArray(val, num)
|
||||
console.log(navList.value)
|
||||
},
|
||||
{deep: true, immediate: true}
|
||||
)
|
||||
|
||||
const handleClick = (link: any) => {
|
||||
navigateTo(link)
|
||||
}
|
||||
|
||||
const swiperChange = (e: any) => {
|
||||
swiperCurrent.value = e.detail.current
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<!-- #ifndef H5 -->
|
||||
<u-sticky h5-nav-height="0" bg-color="transparent">
|
||||
<u-navbar
|
||||
:class="{ 'fixed top-0 z-10': isLargeScreen }"
|
||||
:is-back="false"
|
||||
:is-fixed="true"
|
||||
:title="metaData.title"
|
||||
:custom-title="metaData.title_type == 2"
|
||||
:border-bottom="false"
|
||||
:title-bold="true"
|
||||
:background="{ background: 'rgba(256,256, 256, 0)' }"
|
||||
:title-color="percent > 0.5 ? '#000' : metaData.text_color == 1 ? '#fff' : '#000'"
|
||||
>
|
||||
<template #default>
|
||||
<navigator
|
||||
url="/pages/search/search"
|
||||
class="mini-search"
|
||||
hover-class="none"
|
||||
:style="{ opacity: isLargeScreen ? 1 : percent }"
|
||||
>
|
||||
<u-icon name="search"></u-icon>
|
||||
</navigator>
|
||||
</template>
|
||||
<template #title>
|
||||
<image
|
||||
class="!h-[54rpx]"
|
||||
:src="metaData.title_img"
|
||||
mode="widthFix"
|
||||
></image>
|
||||
</template>
|
||||
</u-navbar>
|
||||
</u-sticky>
|
||||
<!-- #endif -->
|
||||
<navigator
|
||||
v-if="!isLargeScreen"
|
||||
url="/pages/search/search"
|
||||
class="px-[24rpx] mt-[24rpx] mb-[30rpx]"
|
||||
:style="{ opacity: 1 - percent }"
|
||||
hover-class="none"
|
||||
>
|
||||
<u-search
|
||||
placeholder="请输入关键词搜索"
|
||||
:height="72"
|
||||
:disabled="true"
|
||||
:show-action="false"
|
||||
bgColor="#ffffff"
|
||||
></u-search>
|
||||
</navigator>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
pageMeta: {
|
||||
type: Object,
|
||||
default: () => []
|
||||
},
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
isLargeScreen: {
|
||||
type: Boolean
|
||||
},
|
||||
percent: {
|
||||
type: Number
|
||||
}
|
||||
})
|
||||
|
||||
const metaData: any = computed(() => {
|
||||
return props.pageMeta[0].content
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mini-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
margin-left: 20rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<view
|
||||
class="banner h-[200rpx] mx-[20rpx] mt-[20rpx] translate-y-0"
|
||||
v-if="showList.length && content.enabled"
|
||||
>
|
||||
<swiper
|
||||
class="swiper h-full"
|
||||
:indicator-dots="showList.length > 1"
|
||||
indicator-active-color="#4173ff"
|
||||
:autoplay="true"
|
||||
>
|
||||
<swiper-item
|
||||
v-for="(item, index) in showList"
|
||||
:key="index"
|
||||
@click="handleClick(item.link)"
|
||||
>
|
||||
<u-image
|
||||
mode="widthFix"
|
||||
width="100%"
|
||||
height="100%"
|
||||
:src="getImageUrl(item.image)"
|
||||
:border-radius="14"
|
||||
/>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { navigateTo } from '@/utils/util'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
const handleClick = (link: any) => {
|
||||
navigateTo(link)
|
||||
}
|
||||
const { getImageUrl } = useAppStore()
|
||||
|
||||
const showList = computed(() => {
|
||||
return props.content.data?.filter((item: any) => item.is_show == '1') || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<view class="user-info mb-[0rpx]">
|
||||
<!-- #ifndef H5 -->
|
||||
<u-sticky
|
||||
h5-nav-height="0"
|
||||
bg-color="transparent"
|
||||
>
|
||||
<u-navbar
|
||||
:is-back="false"
|
||||
:is-fixed="false"
|
||||
:title="metaData.title"
|
||||
:custom-title="metaData.title_type == 2"
|
||||
:border-bottom="false"
|
||||
:title-bold="true"
|
||||
:background="{ background: 'rgba(256,256, 256, 0)' }"
|
||||
:title-color="$theme.navColor"
|
||||
>
|
||||
<template #title>
|
||||
<image
|
||||
class="!h-[54rpx]"
|
||||
:src="metaData.title_img"
|
||||
mode="widthFix"
|
||||
></image>
|
||||
</template>
|
||||
</u-navbar>
|
||||
</u-sticky>
|
||||
<!-- #endif -->
|
||||
<view class="flex items-center justify-between px-[50rpx] pb-[50rpx] pt-[40rpx]">
|
||||
<view
|
||||
v-if="isLogin"
|
||||
class="flex items-center"
|
||||
@click="navigateTo('/pages/user_data/user_data')"
|
||||
>
|
||||
<u-avatar :src="user.avatar" :size="120"></u-avatar>
|
||||
<view class="text-white ml-[20rpx]">
|
||||
<view class="text-2xl">{{ user.nickname }}</view>
|
||||
<view class="text-xs mt-[18rpx]" @click.stop="copy(user.account)">
|
||||
账号:{{ user.account }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<navigator v-else class="flex items-center" hover-class="none" url="/pages/login/login">
|
||||
<u-avatar src="/static/images/user/default_avatar.png" :size="120"></u-avatar>
|
||||
<view class="text-white text-3xl ml-[20rpx]">未登录</view>
|
||||
</navigator>
|
||||
<navigator v-if="isLogin" hover-class="none" url="/pages/user_set/user_set">
|
||||
<u-icon name="setting" color="#fff" :size="48"></u-icon>
|
||||
</navigator>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useCopy } from '@/hooks/useCopy'
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
pageMeta: {
|
||||
type: Object,
|
||||
default: () => []
|
||||
},
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
isLogin: {
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
const { copy } = useCopy()
|
||||
|
||||
const metaData: any = computed(() => {
|
||||
return props.pageMeta[0].content
|
||||
})
|
||||
|
||||
const navigateTo = (url: string) => {
|
||||
uni.navigateTo({
|
||||
url
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-info {
|
||||
background: url(../../../static/images/user/my_topbg.png),
|
||||
linear-gradient(90deg, $u-type-primary, $u-minor-color);
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom;
|
||||
background-size: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user