mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-13 00:50:40 +08:00
feat: 新增公告模块、数据加载hook与多项体验优化
- 新增公告全流程功能:首页公告轮播、公告列表页、公告详情页 - 新增统一数据加载状态hook useDataLoading与配套测试 - 重构配置加载逻辑,新增统一bootstrap静态配置拉取 - 新增维护模式、恋爱模块支持与验证码防刷功能 - 优化投票卡片、联系页、关于页UI样式 - 新增测试页面与全局玻璃卡片样式 - 修复markdown容器内边距与首页生命周期逻辑
This commit is contained in:
@@ -26,9 +26,9 @@ const homePagePath = '/pages/tabbar/home/home'
|
||||
const articleDetailPath = '/pages-blog/article-detail/article-detail'
|
||||
|
||||
// 本地开发快速跳转页面,发布请置为 false
|
||||
const DEV_MODE = false
|
||||
const DEV_MODE = true
|
||||
const DEV_TO_TYPE = 'page' as 'page' | 'tabbar'
|
||||
const DEV_TO_PATH = `${articleDetailPath}?name=01a057b2-3200-74af-8afe-28a054092e82`
|
||||
const DEV_TO_PATH = `/pages-blog/test/test`
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const appConfigStore = useAppConfigStore()
|
||||
@@ -57,11 +57,6 @@ async function getPostIdByQRCode(key: string): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 获取审核模式数据(公开接口 /audit-data,enabled 联动设置页开关) */
|
||||
async function handleAuditMode() {
|
||||
await appConfigStore.fetchAuditData()
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
// 本地开发,快速跳转页面,发布请设置 DEV_MODE = false
|
||||
if (DEV_MODE && DEV_TO_PATH) {
|
||||
@@ -78,10 +73,11 @@ onLoad(async (options) => {
|
||||
if (!(await handleCheckPluginAvailable()))
|
||||
return
|
||||
|
||||
// 获取配置
|
||||
// 获取配置(统一 bootstrap: getConfigs + audit-data + love-config 并行一次;
|
||||
// TTL 内直接返回缓存。设计见 .docs/static-config-unified-fetch-design.md)
|
||||
try {
|
||||
const res = await appConfigStore.fetchConfigs()
|
||||
if (!res) {
|
||||
const { ok } = await appConfigStore.bootstrap()
|
||||
if (!ok) {
|
||||
uni.switchTab({ url: homePagePath })
|
||||
return
|
||||
}
|
||||
@@ -98,10 +94,8 @@ onLoad(async (options) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 审计模式数据(公开接口 /audit-data)
|
||||
await handleAuditMode()
|
||||
|
||||
// 两层偏好合并:应用站点默认(L0)到 setting store(内部合并本地差异,含旧数据迁移)
|
||||
// 两层偏好合并:应用站点默认(L0)到 setting store(内部合并本地差异,含旧数据迁移);
|
||||
// 审核模式数据已随 bootstrap 拉取(auditData/auditModeEnabled 即可用)
|
||||
settingStore.applySiteDefaults(collectSiteDefaults(appConfigStore.configs))
|
||||
|
||||
// 启动页已下线(v2.2 ⑤):直接进首页
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 维护页(plugin-uni-halo 维护模式,2026-09-04 客户端接入)
|
||||
* 数据源:getConfigs additive 顶层 maintenance 键(store.configs;仅 scheduled/active
|
||||
* 时插件下发,键缺失=未维护或已到点自动结束)。logo 复用 appConfig.appInfo.logo
|
||||
* (相对路径经 checkUrl/BASE_API 补全)。双态:scheduled 维护预告(倒计时至 startTime)
|
||||
* / active 维护中(倒计时至 endTime);到点自动重拉判定(服务端状态切换/自动结束)。
|
||||
* 设计见插件 .docs/maintenance-config-design.md §8。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkUrl } from '@/utils/url'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IPublicMaintenance } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '站点维护',
|
||||
},
|
||||
})
|
||||
|
||||
type ViewState = 'loading' | 'normal' | 'error' | 'maintenance'
|
||||
|
||||
const store = useAppConfigStore()
|
||||
|
||||
const viewState = ref<ViewState>('loading')
|
||||
const maintenance = ref<IPublicMaintenance | null>(null)
|
||||
const nowMs = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let refreshing = false
|
||||
|
||||
const isScheduled = computed(() => maintenance.value?.status === 'scheduled')
|
||||
const title = computed(() => maintenance.value?.title || (isScheduled.value ? '即将维护' : '站点维护中'))
|
||||
const description = computed(() => maintenance.value?.description || '')
|
||||
|
||||
/** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */
|
||||
const appLogo = computed(() => {
|
||||
const appInfo = store.configs.appConfig?.appInfo
|
||||
const logo = appInfo && typeof appInfo === 'object'
|
||||
? (appInfo as { logo?: string }).logo
|
||||
: ''
|
||||
return logo ? checkUrl(logo) : ''
|
||||
})
|
||||
|
||||
/** 倒计时目标:scheduled → startTime / active → endTime */
|
||||
const countdownTarget = computed(() => {
|
||||
const info = maintenance.value
|
||||
if (!info)
|
||||
return ''
|
||||
return isScheduled.value ? info.startTime || '' : info.endTime || ''
|
||||
})
|
||||
|
||||
const countdownPrefix = computed(() => (isScheduled.value ? '距维护开始还有' : '预计恢复还有'))
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const target = countdownTarget.value
|
||||
if (!target)
|
||||
return ''
|
||||
const remaining = new Date(target).getTime() - nowMs.value
|
||||
if (!Number.isFinite(remaining) || remaining <= 0)
|
||||
return ''
|
||||
const total = Math.floor(remaining / 1000)
|
||||
const days = Math.floor(total / 86400)
|
||||
const hours = Math.floor((total % 86400) / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const seconds = total % 60
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return days > 0
|
||||
? `${days}天${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||
})
|
||||
|
||||
function startTimer() {
|
||||
stopTimer()
|
||||
timer = setInterval(tick, 1000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 每秒刷新倒计时;目标时刻已到 → 重拉一次(服务端可能已切换 scheduled→active 或自动结束) */
|
||||
function tick() {
|
||||
nowMs.value = Date.now()
|
||||
const target = countdownTarget.value
|
||||
if (!target)
|
||||
return
|
||||
const remaining = new Date(target).getTime() - nowMs.value
|
||||
if (Number.isFinite(remaining) && remaining <= 0)
|
||||
void load(true)
|
||||
}
|
||||
|
||||
async function load(force = false) {
|
||||
if (refreshing)
|
||||
return
|
||||
refreshing = true
|
||||
viewState.value = 'loading'
|
||||
maintenance.value = null
|
||||
stopTimer()
|
||||
try {
|
||||
const { ok } = await store.bootstrap({ force })
|
||||
const info = store.configs.maintenance
|
||||
if (info) {
|
||||
maintenance.value = info
|
||||
viewState.value = 'maintenance'
|
||||
startTimer()
|
||||
}
|
||||
else if (ok) {
|
||||
// 拉取成功但无 maintenance 键:未维护(或已到点自动结束)→ 服务正常
|
||||
viewState.value = 'normal'
|
||||
}
|
||||
else {
|
||||
viewState.value = 'error'
|
||||
}
|
||||
}
|
||||
catch {
|
||||
viewState.value = 'error'
|
||||
}
|
||||
finally {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.switchTab({ url: '/pages/tabbar/home/home' })
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
stopTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="maintenance-page min-h-screen bg-[#fafafa] pb-16">
|
||||
<!-- 加载中 -->
|
||||
<view v-if="viewState === 'loading'" class="flex flex-col items-center justify-center py-48">
|
||||
<text class="text-[26rpx] text-[#999]">
|
||||
加载中...
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 服务正常(未维护) -->
|
||||
<view v-else-if="viewState === 'normal'" class="flex flex-col items-center justify-center px-10 py-48 text-center">
|
||||
<text class="text-[64rpx]">
|
||||
✅
|
||||
</text>
|
||||
<text class="mt-6 text-[28rpx] text-[#333]">
|
||||
当前服务正常,无需维护
|
||||
</text>
|
||||
<text class="mt-2 text-[24rpx] text-[#999]">
|
||||
如果仍然无法访问,请稍后重试或联系站长
|
||||
</text>
|
||||
<view class="mt-10">
|
||||
<wd-button type="primary" round @click="goHome">
|
||||
返回首页
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 拉取失败(通常为服务器停机中) -->
|
||||
<view v-else-if="viewState === 'error'" class="flex flex-col items-center justify-center px-10 py-48 text-center">
|
||||
<text class="text-[64rpx]">
|
||||
⚠️
|
||||
</text>
|
||||
<text class="mt-6 text-[28rpx] text-[#333]">
|
||||
服务暂时无法访问
|
||||
</text>
|
||||
<text class="mt-2 text-[24rpx] text-[#999]">
|
||||
站点可能正在维护中,请稍后重试
|
||||
</text>
|
||||
<view class="mt-10">
|
||||
<wd-button type="primary" plain round @click="load(true)">
|
||||
重新加载
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 维护预告 / 维护中 -->
|
||||
<view v-else class="flex flex-col items-center px-8 pt-24">
|
||||
<image
|
||||
v-if="appLogo"
|
||||
class="h-[150rpx] w-[150rpx] border border-[#eee] rounded-full bg-white"
|
||||
:src="appLogo"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<view class="mt-8 flex items-center justify-center">
|
||||
<view
|
||||
class="rounded-full px-5 py-1 text-[22rpx]"
|
||||
:class="isScheduled ? 'bg-[#e8f1ff] text-[#1e6fff]' : 'bg-[#fdeef1] text-[#f83856]'"
|
||||
>
|
||||
{{ isScheduled ? '维护预告' : '维护中' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mt-6 text-center text-[40rpx] text-[#222] font-bold">
|
||||
{{ title }}
|
||||
</view>
|
||||
|
||||
<view v-if="countdownText" class="mt-8 flex flex-col items-center">
|
||||
<text class="text-[24rpx] text-[#999]">
|
||||
{{ countdownPrefix }}
|
||||
</text>
|
||||
<text
|
||||
class="mt-2 text-[44rpx] font-bold tabular-nums"
|
||||
:class="isScheduled ? 'text-[#1e6fff]' : 'text-[#f83856]'"
|
||||
>
|
||||
{{ countdownText }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="description" class="mt-10 w-full rounded-2xl bg-white p-6">
|
||||
<mp-html
|
||||
:content="description"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="!description && !countdownText" class="mt-10 text-center text-[24rpx] text-[#999]">
|
||||
请耐心等待,维护完成后将自动恢复访问
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.maintenance-page {
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+299
-313
@@ -1,343 +1,329 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
/**
|
||||
* 关于页(源自旧项目 pages/tabbar/about/about.vue,新建复刻)
|
||||
* 功能:博主信息 + 站点统计 + 功能导航 + 版权
|
||||
* 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title + 彩色图标块)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getBlogStatistics } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { checkHasAdminLogin } from '@/utils/auth'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IBlogStats } from '@/api/types/halo'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getBlogStatistics } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { checkHasAdminLogin } from '@/utils/auth'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IBlogStats } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '关于',
|
||||
enablePullDownRefresh: true,
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '关于',
|
||||
enablePullDownRefresh: true,
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
|
||||
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
|
||||
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
|
||||
|
||||
/* ---------------- 计算属性 ---------------- */
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as
|
||||
| { nickname?: string, avatar?: string, description?: string }
|
||||
| undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
description: blogger?.description || '',
|
||||
}
|
||||
})
|
||||
/* ---------------- 计算属性 ---------------- */
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as
|
||||
| { nickname ?: string, avatar ?: string, description ?: string }
|
||||
| undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
description: blogger?.description || '',
|
||||
}
|
||||
})
|
||||
|
||||
const pageConfig = computed(() => haloConfigs.value.pageConfig?.aboutConfig as
|
||||
| { bgImageUrl?: string, waveImageUrl?: string }
|
||||
| undefined)
|
||||
const pageConfig = computed(() => haloConfigs.value.pageConfig?.aboutConfig as
|
||||
| { bgImageUrl ?: string, waveImageUrl ?: string }
|
||||
| undefined)
|
||||
|
||||
const calcProfileStyle = computed(() => ({
|
||||
backgroundImage: `url(${checkImageUrl(pageConfig.value?.bgImageUrl)})`,
|
||||
}))
|
||||
const calcProfileStyle = computed(() => ({
|
||||
backgroundImage: `url(${checkImageUrl(pageConfig.value?.bgImageUrl)})`,
|
||||
}))
|
||||
|
||||
const calcWaveUrl = computed(() => checkImageUrl(pageConfig.value?.waveImageUrl))
|
||||
const calcWaveUrl = computed(() => checkImageUrl(pageConfig.value?.waveImageUrl))
|
||||
|
||||
const basicConfig = computed(() => haloConfigs.value.basicConfig as
|
||||
| {
|
||||
copyrightConfig?: { enabled?: boolean, content?: string }
|
||||
disclaimers?: { enabled?: boolean }
|
||||
showAboutSystem?: boolean
|
||||
}
|
||||
| undefined)
|
||||
const basicConfig = computed(() => haloConfigs.value.basicConfig as
|
||||
| {
|
||||
copyrightConfig ?: { enabled ?: boolean, content ?: string }
|
||||
disclaimers ?: { enabled ?: boolean }
|
||||
showAboutSystem ?: boolean
|
||||
}
|
||||
| undefined)
|
||||
|
||||
const copyrightConfig = computed(() => basicConfig.value?.copyrightConfig)
|
||||
const copyrightConfig = computed(() => basicConfig.value?.copyrightConfig)
|
||||
|
||||
const loveEnabled = computed(() => !!(haloConfigs.value.loveConfig as { loveEnabled?: boolean } | undefined)?.loveEnabled)
|
||||
const socialEnabled = computed(() => !!(haloConfigs.value.authorConfig?.social as { enabled?: boolean } | undefined)?.enabled)
|
||||
const loveEnabled = computed(() => !!(haloConfigs.value.loveConfig as { loveEnabled ?: boolean } | undefined)?.loveEnabled)
|
||||
const socialEnabled = computed(() => !!(haloConfigs.value.authorConfig?.social as { enabled ?: boolean } | undefined)?.enabled)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const statisticsShowMore = ref(false)
|
||||
const statistics = ref<IBlogStats>({ post: 0, comment: 0, category: 0, visit: 0, upvote: 0 })
|
||||
const navList = ref<{
|
||||
key: string
|
||||
title: string
|
||||
icon: string
|
||||
iconColor: string
|
||||
rightText: string
|
||||
path: string | null
|
||||
isAdmin?: boolean
|
||||
openType?: string
|
||||
show: boolean
|
||||
}[]>([])
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const statisticsShowMore = ref(false)
|
||||
const statistics = ref<IBlogStats>({ post: 0, comment: 0, category: 0, visit: 0, upvote: 0 })
|
||||
|
||||
/* ---------------- 功能导航 ---------------- */
|
||||
async function handleGetNavList() {
|
||||
let isWx = false
|
||||
// #ifdef MP-WEIXIN
|
||||
isWx = true
|
||||
// #endif
|
||||
/** 主行统计(常驻展示) */
|
||||
const allStats = computed(() => [
|
||||
{ key: 'post', label: '内容', value: statistics.value.post },
|
||||
{ key: 'visit', label: '访客', value: statistics.value.visit },
|
||||
{ key: 'category', label: '分类', value: statistics.value.category },
|
||||
{ key: 'comment', label: '评论', value: statistics.value.comment },
|
||||
{ key: 'upvote', label: '点赞', value: statistics.value.upvote },
|
||||
])
|
||||
|
||||
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
|
||||
interface INavItem {
|
||||
key : string
|
||||
title : string
|
||||
icon : string
|
||||
/** 图标块背景色(与首页快捷导航同色板,同一功能同色) */
|
||||
bgColor : string
|
||||
rightText : string
|
||||
path : string | null
|
||||
isAdmin ?: boolean
|
||||
openType ?: string
|
||||
show : boolean
|
||||
/** 分组:blog=博客功能 more=更多信息 */
|
||||
group : 'blog' | 'more'
|
||||
}
|
||||
|
||||
navList.value = [
|
||||
{
|
||||
key: 'data-visual',
|
||||
title: '数据看板',
|
||||
icon: 'chart',
|
||||
iconColor: '#2196f3',
|
||||
rightText: '站点数据可视化',
|
||||
path: '/pages-blog/data-visual/data-visual',
|
||||
show: dataVisualAvailable,
|
||||
},
|
||||
{
|
||||
key: 'archives',
|
||||
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
|
||||
icon: 'folder',
|
||||
iconColor: '#f44336',
|
||||
rightText: calcAuditModeEnabled.value ? '全部已归档内容' : '全部已归档文章',
|
||||
path: '/pages-blog/archives/archives',
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
key: 'love',
|
||||
title: '恋爱日记',
|
||||
icon: 'heart',
|
||||
iconColor: '#f44336',
|
||||
rightText: '博主的恋爱日记',
|
||||
path: '/pages-blog/love/love',
|
||||
show: loveEnabled.value,
|
||||
},
|
||||
{
|
||||
key: 'vote',
|
||||
title: '投票中心',
|
||||
icon: 'box',
|
||||
iconColor: '#f44336',
|
||||
rightText: '查看和进行投票',
|
||||
path: '/pages-blog/votes/votes',
|
||||
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
|
||||
},
|
||||
{
|
||||
key: 'friend-links',
|
||||
title: '友情链接',
|
||||
icon: 'link',
|
||||
iconColor: '#2196f3',
|
||||
rightText: '看看博主朋友们吧',
|
||||
path: '/pages-blog/friend-links/friend-links',
|
||||
show: calcLinksPluginEnabled.value,
|
||||
},
|
||||
{
|
||||
key: 'disclaimers',
|
||||
title: '免责声明',
|
||||
icon: 'map',
|
||||
iconColor: '#f44336',
|
||||
rightText: '博客内容免责声明',
|
||||
path: '/pages-blog/disclaimers/disclaimers',
|
||||
show: !!basicConfig.value?.disclaimers?.enabled,
|
||||
},
|
||||
{
|
||||
key: 'contact-blogger',
|
||||
title: '联系博主',
|
||||
icon: 'message',
|
||||
iconColor: '#ff9800',
|
||||
rightText: '博主常用联系方式',
|
||||
path: '/pages-blog/contact/contact',
|
||||
show: socialEnabled.value,
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: '关于项目',
|
||||
icon: 'info',
|
||||
iconColor: '#2196f3',
|
||||
rightText: '小莫唐尼开源项目',
|
||||
path: '/pages-blog/about/about',
|
||||
show: !!basicConfig.value?.showAboutSystem,
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
title: '偏好设置',
|
||||
icon: 'settings',
|
||||
iconColor: '#03a9f4',
|
||||
rightText: '首页布局、卡片样式等本地偏好',
|
||||
path: '/pages-blog/setting/setting',
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
const navList = ref<INavItem[]>([])
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
try {
|
||||
const res = await getBlogStatistics()
|
||||
statistics.value = res.data
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取统计失败', err)
|
||||
uni.showToast({ icon: 'none', title: t('common.loadFailedRetry') })
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
/** 分组渲染(过滤后空组整组隐藏) */
|
||||
const calcNavGroups = computed(() => {
|
||||
const visible = navList.value.filter(n => n.show)
|
||||
const groupDefs : { key : 'blog' | 'more', title : string }[] = [
|
||||
{ key: 'blog', title: '博客功能' },
|
||||
{ key: 'more', title: '其他功能' },
|
||||
]
|
||||
return groupDefs
|
||||
.map(def => ({ ...def, items: visible.filter(n => n.group === def.key) }))
|
||||
.filter(group => group.items.length > 0)
|
||||
})
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnNav(data: { path: string | null, isAdmin?: boolean }) {
|
||||
const { path, isAdmin } = data
|
||||
if (!path)
|
||||
return
|
||||
/* ---------------- 功能导航 ---------------- */
|
||||
async function handleGetNavList() {
|
||||
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
|
||||
|
||||
// 拦截后台管理页面(需超管登录)
|
||||
if (isAdmin && !checkHasAdminLogin()) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '未登录超管账号或登录状态已过期,是否立即登录?',
|
||||
showCancel: true,
|
||||
cancelText: '否',
|
||||
cancelColor: '#999999',
|
||||
confirmText: '是',
|
||||
confirmColor: '#03a9f4',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/auth/login' })
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
navList.value = [
|
||||
{
|
||||
key: 'data-visual',
|
||||
title: '数据看板',
|
||||
icon: 'chart',
|
||||
bgColor: 'rgba(102, 60, 201, 0.95)',
|
||||
rightText: '站点数据可视化',
|
||||
path: '/pages-blog/data-visual/data-visual',
|
||||
show: dataVisualAvailable,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'archives',
|
||||
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
|
||||
icon: 'folder',
|
||||
bgColor: 'rgba(3, 169, 244, 0.95)',
|
||||
rightText: calcAuditModeEnabled.value ? '全部已归档内容' : '全部已归档文章',
|
||||
path: '/pages-blog/archives/archives',
|
||||
show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'love',
|
||||
title: '恋爱日记',
|
||||
icon: 'heart',
|
||||
bgColor: 'rgba(255, 76, 103, 0.95)',
|
||||
rightText: '博主的恋爱日记',
|
||||
path: '/pages-blog/love/love',
|
||||
show: loveEnabled.value,
|
||||
// show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'vote',
|
||||
title: '投票中心',
|
||||
icon: 'box',
|
||||
bgColor: 'rgba(0, 188, 212, 0.95)',
|
||||
rightText: '查看和进行投票',
|
||||
path: '/pages-blog/votes/votes',
|
||||
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
|
||||
// show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'friend-links',
|
||||
title: '友情链接',
|
||||
icon: 'link',
|
||||
bgColor: 'rgba(0, 150, 136, 0.95)',
|
||||
rightText: '看看博主朋友们吧',
|
||||
path: '/pages-blog/friend-links/friend-links',
|
||||
show: calcLinksPluginEnabled.value,
|
||||
// show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'disclaimers',
|
||||
title: '免责声明',
|
||||
icon: 'map',
|
||||
bgColor: 'rgba(121, 85, 72, 0.95)',
|
||||
rightText: '博客内容免责声明',
|
||||
path: '/pages-blog/disclaimers/disclaimers',
|
||||
show: !!basicConfig.value?.disclaimers?.enabled,
|
||||
// show: true,
|
||||
group: 'more',
|
||||
},
|
||||
{
|
||||
key: 'contact-blogger',
|
||||
title: '联系博主',
|
||||
icon: 'message',
|
||||
bgColor: 'rgba(255, 152, 0, 0.95)',
|
||||
rightText: '博主常用联系方式',
|
||||
path: '/pages-blog/contact/contact',
|
||||
show: socialEnabled.value,
|
||||
// show: true,
|
||||
group: 'more',
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: '关于项目',
|
||||
icon: 'info',
|
||||
bgColor: 'rgba(96, 125, 139, 0.95)',
|
||||
rightText: '小莫唐尼开源项目',
|
||||
path: '/pages-blog/about/about',
|
||||
show: !!basicConfig.value?.showAboutSystem,
|
||||
// show: true,
|
||||
group: 'more',
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
title: '偏好设置',
|
||||
icon: 'settings',
|
||||
bgColor: 'rgba(121, 134, 203, 0.95)',
|
||||
rightText: '首页布局、卡片样式等本地偏好',
|
||||
path: '/pages-blog/setting/setting',
|
||||
show: true,
|
||||
group: 'more',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
try {
|
||||
const res = await getBlogStatistics()
|
||||
statistics.value = res.data
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取统计失败', err)
|
||||
uni.showToast({ icon: 'none', title: t('common.loadFailedRetry') })
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
watch(haloConfigs, () => {
|
||||
handleGetNavList()
|
||||
}, { deep: true, immediate: true })
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnNav(data : { path : string | null, isAdmin ?: boolean }) {
|
||||
const { path, isAdmin } = data
|
||||
if (!path)
|
||||
return
|
||||
|
||||
handleGetData()
|
||||
// 拦截后台管理页面(需超管登录)
|
||||
if (isAdmin && !checkHasAdminLogin()) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '未登录超管账号或登录状态已过期,是否立即登录?',
|
||||
showCancel: true,
|
||||
cancelText: '否',
|
||||
cancelColor: '#999999',
|
||||
confirmText: '是',
|
||||
confirmColor: '#03a9f4',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/auth/login' })
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetData()
|
||||
})
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
watch(haloConfigs, () => {
|
||||
handleGetNavList()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
handleGetData()
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page min-h-screen w-screen pb-6">
|
||||
<!-- 博主信息 -->
|
||||
<view class="blogger-info relative h-[600rpx] w-full" :style="[calcProfileStyle]">
|
||||
<image class="avatar absolute left-1/2 top-[200rpx] z-2 h-[130rpx] w-[130rpx] border-6 border-white rounded-full -translate-x-1/2" :src="bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="profile absolute left-0 top-[340rpx] z-6 w-full text-center text-white">
|
||||
<view class="author text-[34rpx] font-bold">
|
||||
{{ bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="desc mt-4 px-12 text-[26rpx] opacity-90">
|
||||
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
|
||||
</view>
|
||||
</view>
|
||||
<image v-if="calcWaveUrl" :src="calcWaveUrl" mode="scaleToFill" class="gif-wave absolute bottom-0 left-0 z-99 h-[100rpx] w-full" style="mix-blend-mode: screen;" />
|
||||
</view>
|
||||
<view class="box-border bg-page min-h-screen w-screen pb-8">
|
||||
<!-- 头部:博主信息(背景图 + 遮罩 + wave,内容区做状态栏适配) -->
|
||||
<view class="blogger-info relative h-76 w-full bg-cover bg-no-repeat" :style="[calcProfileStyle]">
|
||||
<!-- 背景遮罩 -->
|
||||
<view class="absolute left-0 top-0 z-0 h-full w-full backdrop-blur-[2rpx] bg-black/30" />
|
||||
<view class="relative z-6 h-full flex flex-col items-center justify-center pb-[140rpx] pt-safe">
|
||||
<image class="uh-global-card-glass h-20 w-20 rounded-full" :src="bloggerInfo.avatar"
|
||||
mode="aspectFill" />
|
||||
<view class="mt-4 text-lg text-white font-bold text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
|
||||
{{ bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view
|
||||
class="desc mt-2 px-10 text-center text-[26rpx] text-white/90 leading-relaxed text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
|
||||
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
|
||||
</view>
|
||||
</view>
|
||||
<image v-if="calcWaveUrl" :src="calcWaveUrl" mode="scaleToFill"
|
||||
class="gif-wave absolute bottom-0 left-0 z-99 h-[100rpx] w-full" style="mix-blend-mode: screen;" />
|
||||
</view>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<view class="statistics-wrap overflow-hidden rounded-b-3xl bg-white shadow-sm">
|
||||
<view class="statistics flex pb-3 pt-3">
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #ff9800;">
|
||||
{{ statistics.post }}
|
||||
</view>
|
||||
<view class="mt-1 text-center text-[24rpx] text-[#999]">
|
||||
内容数量
|
||||
</view>
|
||||
</view>
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #4caf50;">
|
||||
{{ statistics.visit }}
|
||||
</view>
|
||||
<view class="mt-1 text-[24rpx] text-[#999]">
|
||||
访客数量
|
||||
</view>
|
||||
</view>
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #2196f3;">
|
||||
{{ statistics.category }}
|
||||
</view>
|
||||
<view class="mt-1 text-center text-[24rpx] text-[#999]">
|
||||
分类总数
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="statisticsShowMore" class="statistics flex border-t-2 border-[#fafafa] pb-3 pt-3">
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #ff9800;">
|
||||
{{ statistics.comment }}
|
||||
</view>
|
||||
<view class="mt-1 text-center text-[24rpx] text-[#999]">
|
||||
评论数量
|
||||
</view>
|
||||
</view>
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #2196f3;">
|
||||
{{ statistics.upvote }}
|
||||
</view>
|
||||
<view class="mt-1 text-[24rpx] text-[#999]">
|
||||
点赞数量
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="show-more-btn pb-4 text-center text-[24rpx] text-[#999]" @click="statisticsShowMore = !statisticsShowMore">
|
||||
{{ statisticsShowMore ? '收起' : '展开' }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- 站点统计(上浮玻璃卡,与头部衔接) -->
|
||||
<view class="uh-global-card-glass border relative flex z-100 mx-4 rounded-2xl -mt-12">
|
||||
<view v-for="item in allStats" :key="item.key" class="flex-1 py-6 text-center">
|
||||
<view class="text-lg text-gray-900 font-bold">
|
||||
{{ item.value }}
|
||||
</view>
|
||||
<view class="mt-1 text-xs text-gray-500">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能导航 -->
|
||||
<view class="nav-wrap mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<template v-for="nav in navList.filter(n => n.show)" :key="nav.key">
|
||||
<view class="nav-item flex items-center justify-between border-b-2 border-[#f5f5f5] px-3 py-7" @click="handleOnNav(nav)">
|
||||
<view class="nav-left flex items-center gap-4">
|
||||
<wd-icon :name="nav.icon" size="18px" :color="nav.iconColor" />
|
||||
<text class="nav-title text-[28rpx] text-[#303133]">{{ nav.title }}</text>
|
||||
</view>
|
||||
<view class="nav-right flex items-center gap-2">
|
||||
<text class="nav-right-text text-[24rpx] text-[#c0c4cc]">{{ nav.rightText }}</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#c0c4cc" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<!-- 功能导航(分组玻璃卡) -->
|
||||
<template v-for="group in calcNavGroups" :key="group.key">
|
||||
<uh-section-title class="mx-4 mb-3 mt-8">
|
||||
{{ group.title }}
|
||||
</uh-section-title>
|
||||
<view class="nav-wrap uh-global-card-glass mx-4 overflow-hidden rounded-2xl">
|
||||
<view v-for="(nav, index) in group.items" :key="nav.key"
|
||||
class="nav-item flex items-center justify-between px-4"
|
||||
:class="index < group.items.length - 1 ? 'border-b border-b-solid border-black/5' : ''" @click="handleOnNav(nav)">
|
||||
<view class="nav-left flex items-center gap-3 py-3">
|
||||
<view class="h-9 w-9 flex items-center justify-center rounded-xl"
|
||||
:style="{ backgroundColor: nav.bgColor }">
|
||||
<wd-icon :name="nav.icon" size="20px" color="#ffffff" />
|
||||
</view>
|
||||
<text class="nav-title text-sm text-gray-900 font-bold">{{ nav.title }}</text>
|
||||
</view>
|
||||
<view class="nav-right flex items-center gap-2">
|
||||
<text class="nav-right-text text-xs text-gray-400">{{ nav.rightText }}</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 版权 -->
|
||||
<view v-if="copyrightConfig?.enabled" class="copyright mt-10 px-6 text-center text-[22rpx] text-[#c0c4c7]">
|
||||
<view>{{ copyrightConfig.content }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-page {
|
||||
.blogger-info {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: rgb(0 0 0 / 30%);
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-wrap {
|
||||
.nav-item {
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- 版权 -->
|
||||
<view v-if="copyrightConfig?.enabled" class="mt-6 px-6 text-center text-xs text-gray-400">
|
||||
<view>{{ copyrightConfig.content }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -1,183 +1,183 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getCategoryList, getCategoryPostList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkThumbnailUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import { useDataLoadingStatus, DataLoadingStatusEnum } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ICategory, IPost } from '@/api/types/halo'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getCategoryList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkThumbnailUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ICategory } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '分类',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '分类',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
|
||||
const categoryConfig = computed(() => haloConfigs.value.pageConfig?.categoryConfig)
|
||||
const categoryConfig = computed(() => haloConfigs.value.pageConfig?.categoryConfig)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const queryParams = ref({
|
||||
size: 20,
|
||||
page: 1,
|
||||
fieldSelector: ['spec.hideFromList=false'],
|
||||
})
|
||||
const hasNext = ref(false)
|
||||
const dataList = ref<ICategory[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const queryParams = ref({
|
||||
size: 20,
|
||||
page: 1,
|
||||
fieldSelector: ['spec.hideFromList=false'],
|
||||
})
|
||||
const hasNext = ref(false)
|
||||
const dataList = ref<ICategory[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
|
||||
function handleResetInit() {
|
||||
dataList.value = []
|
||||
queryParams.value.page = 1
|
||||
hasNext.value = false
|
||||
isLoadMore.value = false
|
||||
loadMoreText.value = t('common.loading')
|
||||
}
|
||||
function handleResetInit() {
|
||||
dataList.value = []
|
||||
queryParams.value.page = 1
|
||||
hasNext.value = false
|
||||
isLoadMore.value = false
|
||||
loadMoreText.value = t('common.loading')
|
||||
}
|
||||
|
||||
function handleInitPage() {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
}
|
||||
function handleInitPage() {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
// 审核模式
|
||||
if (calcAuditModeEnabled.value) {
|
||||
const auditCategoryNames = appConfigStore.auditData.spec?.categories || []
|
||||
try {
|
||||
const res = await getCategoryList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(item => auditCategoryNames.includes(item.metadata.name))
|
||||
.map(item => ({
|
||||
...item,
|
||||
postCount: item.postCount ?? 0,
|
||||
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
|
||||
}))
|
||||
const orderMap = new Map(auditCategoryNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
dataList.value = filtered
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
// 审核模式
|
||||
if (calcAuditModeEnabled.value) {
|
||||
const auditCategoryNames = appConfigStore.auditData.spec?.categories || []
|
||||
try {
|
||||
const res = await getCategoryList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(item => auditCategoryNames.includes(item.metadata.name))
|
||||
.map(item => ({
|
||||
...item,
|
||||
postCount: item.postCount ?? 0,
|
||||
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
|
||||
}))
|
||||
const orderMap = new Map(auditCategoryNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
dataList.value = filtered
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLoadMore.value) {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
if (!isLoadMore.value) {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
try {
|
||||
const res = await getCategoryList({ ...queryParams.value })
|
||||
try {
|
||||
const res = await getCategoryList({ ...queryParams.value })
|
||||
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
|
||||
const tempItems = res.data.items.map(item => ({
|
||||
...item,
|
||||
postCount: item.postCount ?? 0,
|
||||
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
|
||||
}))
|
||||
const tempItems = res.data.items.map(item => ({
|
||||
...item,
|
||||
postCount: item.postCount ?? 0,
|
||||
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
|
||||
}))
|
||||
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
function handleToCategory(category: ICategory) {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
|
||||
})
|
||||
}
|
||||
|
||||
function handleToCategory(category : ICategory) {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
|
||||
})
|
||||
}
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
|
||||
onMounted(() => {
|
||||
handleInitPage()
|
||||
})
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onPullDownRefresh(() => {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
handleInitPage()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
onReachBottom(() => {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="bg-page min-h-screen w-screen flex flex-col p-3 box-border">
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loadingStatus !== DataLoadingStatusEnum.Success">
|
||||
<uh-data-loading :loading-status="loadingStatus" />
|
||||
</view>
|
||||
<view class="box-border min-h-screen w-screen flex flex-col bg-page p-3">
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loadingStatus !== DataLoadingStatusEnum.Success">
|
||||
<uh-data-loading :loading-status="loadingStatus" />
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<view class="grid grid-cols-2 gap-3">
|
||||
<view v-for="(item, index) in dataList" :key="index"
|
||||
class="relative w-full box-border rounded-xl overflow-hidden uh-global-card-glass"
|
||||
@click="handleToCategory(item)">
|
||||
<image v-if="item.spec.cover" class="block h-32 w-full" :src="item.spec.cover" mode="aspectFill" />
|
||||
<view class="absolute bottom-0 left-0 h-[140rpx] w-full bg-gradient-to-b from-black/0 to-black/30" />
|
||||
<view class="absolute bottom-0 left-0 box-border w-full p-2.5 flex flex-col gap-1">
|
||||
<text class="text-sm text-white font-bold truncate">
|
||||
{{ item.spec.displayName }}
|
||||
</text>
|
||||
<text class="text-xs text-white opacity-80">
|
||||
共 {{ item.postCount }} 篇文章
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="w-full py-5 text-center text-xs text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
<block v-else>
|
||||
<view class="grid grid-cols-2 gap-3">
|
||||
<view
|
||||
v-for="(item, index) in dataList" :key="index"
|
||||
class="uh-global-card-glass relative box-border w-full overflow-hidden rounded-xl"
|
||||
@click="handleToCategory(item)"
|
||||
>
|
||||
<image v-if="item.spec.cover" class="block h-32 w-full" :src="item.spec.cover" mode="aspectFill" />
|
||||
<view class="absolute bottom-0 left-0 h-[140rpx] w-full from-black/0 to-black/30 bg-gradient-to-b" />
|
||||
<view class="absolute bottom-0 left-0 box-border w-full flex flex-col gap-1 p-2.5">
|
||||
<text class="truncate text-sm text-white font-bold">
|
||||
{{ item.spec.displayName }}
|
||||
</text>
|
||||
<text class="text-xs text-white opacity-80">
|
||||
共 {{ item.postCount }} 篇文章
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="w-full py-5 text-center text-xs text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getPostList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
@@ -71,7 +71,7 @@
|
||||
try {
|
||||
const res = await getPostList({ page: 1, size: 0, sort: ['spec.publishTime,desc'] })
|
||||
const filtered = res.data.items.filter(item => auditPostNames.includes(item.metadata.name))
|
||||
articleList.value = filtered.map((item)=>{
|
||||
articleList.value = filtered.map((item) => {
|
||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar);
|
||||
return item;
|
||||
})
|
||||
@@ -100,7 +100,7 @@
|
||||
result.value.hasNext = res.data.hasNext
|
||||
articleList.value = (isLoadMore.value
|
||||
? articleList.value.concat(res.data.items)
|
||||
: res.data.items).map((item)=>{
|
||||
: res.data.items).map((item) => {
|
||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar);
|
||||
return item;
|
||||
})
|
||||
@@ -147,13 +147,6 @@
|
||||
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(() => {
|
||||
uni.setNavigationBarTitle({ title: t('page.home.title') })
|
||||
})
|
||||
|
||||
watch(haloConfigs, () => {
|
||||
// 配置就绪后重新拉取(导航显隐依赖配置)
|
||||
}, { deep: true })
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
isLoadMore.value = false
|
||||
@@ -177,7 +170,9 @@
|
||||
})
|
||||
|
||||
// 首次加载
|
||||
handleQuery()
|
||||
onMounted(() => {
|
||||
handleQuery()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -191,6 +186,9 @@
|
||||
<!-- 轮播-->
|
||||
<uh-home-banner />
|
||||
|
||||
<!-- 公告 -->
|
||||
<uh-home-notify />
|
||||
|
||||
<!-- 快捷导航 -->
|
||||
<uh-home-quick-nav />
|
||||
|
||||
@@ -207,7 +205,7 @@
|
||||
</view>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
|
||||
|
||||
<view v-if="articleList.length === 0" class="article-empty py-10">
|
||||
<wd-empty description="博主还没有发表任何内容~" />
|
||||
</view>
|
||||
@@ -225,4 +223,5 @@
|
||||
</block>
|
||||
</block>
|
||||
</view>
|
||||
<uh-notify-dialog />
|
||||
</template>
|
||||
@@ -1,495 +1,390 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
/**
|
||||
* 瞬间页(源自旧项目 pages/tabbar/moments/moments.vue,新建复刻)
|
||||
* 功能:瞬间卡片列表(头像/内容/图片/音频/视频/标签) + 分页加载
|
||||
* 设计:社交信息流(实心白纸卡 + 着色昵称 + 朋友圈式不缩进正文),区别于工具页的玻璃拟态
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 玻璃拟态试验:下拉/回弹露出的窗口底色对齐壁纸底部色调
|
||||
backgroundColor: '#f4efff',
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
}
|
||||
})
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname ?: string, avatar ?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
}
|
||||
})
|
||||
|
||||
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
|
||||
const siteName = computed(() => {
|
||||
const appInfo = haloConfigs.value.appConfig?.appInfo as { name?: string } | undefined
|
||||
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
|
||||
})
|
||||
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
|
||||
const siteName = computed(() => {
|
||||
const appInfo = haloConfigs.value.appConfig?.appInfo as { name ?: string } | undefined
|
||||
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
|
||||
})
|
||||
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
|
||||
type MomentCard = IMoment & {
|
||||
images?: { type?: string, url: string }[]
|
||||
videos?: { id?: string, url: string }[]
|
||||
audios?: { type?: string, url: string }[]
|
||||
spec: IMoment['spec'] & { newHtml?: string }
|
||||
}
|
||||
const dataList = ref<MomentCard[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
|
||||
type MomentCard = IMoment & {
|
||||
images ?: { type ?: string, url : string }[]
|
||||
videos ?: { id ?: string, url : string }[]
|
||||
audios ?: { type ?: string, url : string }[]
|
||||
spec : IMoment['spec'] & { newHtml ?: string }
|
||||
}
|
||||
const dataList = ref<MomentCard[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
|
||||
/** 移除内容中的 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString: string): string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
|
||||
const calcTagColors = computed(() => {
|
||||
return dataList.value.map(moment =>
|
||||
(moment.spec.tags || []).map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f')),
|
||||
)
|
||||
})
|
||||
|
||||
/** 瞬间项映射(spec.content.medium 拆分为 images/videos/audios + 内容 tag 清理 + 作者兜底) */
|
||||
function mapMomentItem(item: IMoment): MomentCard {
|
||||
const medium = (item.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = item.owner
|
||||
return {
|
||||
...item,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...item.spec,
|
||||
newHtml: removeTagLinksCompletely(item.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
}
|
||||
/** 移除内容中的 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString : string) : string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
// 审核模式:真实瞬间按 audit-data moments 过滤(数组顺序即展示顺序)
|
||||
const auditMomentNames = appConfigStore.auditData.spec?.moments || []
|
||||
try {
|
||||
const res = await getMomentList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC' && auditMomentNames.includes(x.metadata.name))
|
||||
const orderMap = new Map(auditMomentNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
const tempItems = filtered.map(mapMomentItem)
|
||||
dataList.value = tempItems
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
/** 瞬间项映射(spec.content.medium 拆分为 images/videos/audios + 内容 tag 清理 + 作者兜底) */
|
||||
function mapMomentItem(item : IMoment) : MomentCard {
|
||||
const medium = (item.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = item.owner
|
||||
return {
|
||||
...item,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...item.spec,
|
||||
newHtml: removeTagLinksCompletely(item.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
}
|
||||
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
// 审核模式:真实瞬间按 audit-data moments 过滤(数组顺序即展示顺序)
|
||||
const auditMomentNames = appConfigStore.auditData.spec?.moments || []
|
||||
try {
|
||||
const res = await getMomentList({ page: 1, size: 0 })
|
||||
const filtered = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC' && auditMomentNames.includes(x.metadata.name))
|
||||
const orderMap = new Map(auditMomentNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
const tempItems = filtered.map(mapMomentItem)
|
||||
dataList.value = tempItems
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getMomentList({ ...queryParams.value })
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
const tempItems = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC')
|
||||
.map(mapMomentItem)
|
||||
try {
|
||||
const res = await getMomentList({ ...queryParams.value })
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
const tempItems = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC')
|
||||
.map(mapMomentItem)
|
||||
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(list: { videos?: { id?: string }[] }[]) {
|
||||
stopAllVideos()
|
||||
list.map(item => item.videos || []).flat().forEach((item) => {
|
||||
if (item.id) {
|
||||
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function stopAllVideos(excludesVideoId: string | null = null) {
|
||||
Object.keys(videoContexts.value).forEach((videoId) => {
|
||||
if (!excludesVideoId || excludesVideoId !== videoId) {
|
||||
videoContexts.value[videoId]?.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(list : { videos ?: { id ?: string }[] }[]) {
|
||||
stopAllVideos()
|
||||
list.map(item => item.videos || []).flat().forEach((item) => {
|
||||
if (item.id) {
|
||||
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVideoPlay(videoId: string) {
|
||||
currentVideoId.value = videoId
|
||||
stopAllVideos(videoId)
|
||||
}
|
||||
function stopAllVideos(excludesVideoId : string | null = null) {
|
||||
Object.keys(videoContexts.value).forEach((videoId) => {
|
||||
if (!excludesVideoId || excludesVideoId !== videoId) {
|
||||
videoContexts.value[videoId]?.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVideoPause(videoId: string) {
|
||||
if (currentVideoId.value === videoId) {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
}
|
||||
function onVideoPlay(videoId : string) {
|
||||
currentVideoId.value = videoId
|
||||
stopAllVideos(videoId)
|
||||
}
|
||||
|
||||
function onVideoEnded() {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
function onVideoPause(videoId : string) {
|
||||
if (currentVideoId.value === videoId) {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handlePreview(index: number, list: { url: string }[]) {
|
||||
uni.previewImage({
|
||||
current: index,
|
||||
urls: list.map(item => item.url),
|
||||
})
|
||||
}
|
||||
function onVideoEnded() {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
|
||||
function handleToMomentDetail(moment: IMoment) {
|
||||
if (calcAuditModeEnabled.value)
|
||||
return
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handlePreview(index : number, list : { url : string }[]) {
|
||||
uni.previewImage({
|
||||
current: index,
|
||||
urls: list.map(item => item.url),
|
||||
})
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
function handleToMomentDetail(moment : IMoment) {
|
||||
if (calcAuditModeEnabled.value)
|
||||
return
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time?: string): string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
})
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time ?: string) : string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
handleGetData()
|
||||
})
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="moments-page relative box-border min-h-screen w-screen flex flex-col py-6">
|
||||
<!-- 苹果风玻璃拟态试验:fixed 渐变"壁纸"(多层柔光光斑为卡片毛玻璃取色) -->
|
||||
<view class="moments-wallpaper">
|
||||
<view class="deco deco-blue" />
|
||||
<view class="deco deco-pink" />
|
||||
<view class="deco deco-lavender" />
|
||||
<view class="deco deco-cyan" />
|
||||
<view class="deco deco-lift" />
|
||||
</view>
|
||||
<uh-plugin-unavailable
|
||||
v-if="!uniHaloPluginAvailable"
|
||||
:plugin-id="uniHaloPluginId"
|
||||
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员"
|
||||
@on-refresh="handleGetData"
|
||||
/>
|
||||
<template v-else>
|
||||
<view v-if="loading !== 'success'" class="loading-wrap p-3">
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</view>
|
||||
<view class="box-border min-h-screen w-screen flex flex-col bg-page py-4">
|
||||
<uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="uniHaloPluginId"
|
||||
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员" @on-refresh="handleGetData" />
|
||||
<template v-else>
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading === 'loading'" class="loading-wrap p-3">
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</view>
|
||||
|
||||
<view v-else class="flex flex-col gap-y-4 p-4">
|
||||
<view v-if="dataList.length === 0" class="min-h-[70vh] w-full flex items-center justify-center content-empty">
|
||||
<wd-empty :description="t('common.empty')" />
|
||||
</view>
|
||||
<!-- 加载失败(可重试) -->
|
||||
<uh-data-loading v-else-if="loading === 'error'" :loading-status="loading" min-height="60vh"
|
||||
error-text="瞬间加载失败,请点击重试" @refresh="handleGetData" />
|
||||
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(玻璃) -->
|
||||
<view v-for="moment in dataList" :key="moment.metadata.name" class="moment-glass flex flex-col overflow-hidden rounded-[32rpx]">
|
||||
<view class="head flex items-center p-3 pb-0">
|
||||
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="nickname ml-3">
|
||||
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="release-time mt-1 text-[24rpx] text-[#666]">
|
||||
{{ formatMomentTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="flex flex-col gap-3 px-4">
|
||||
<view v-if="dataList.length === 0"
|
||||
class="min-h-[70vh] w-full flex items-center justify-center content-empty">
|
||||
<wd-empty :description="t('common.empty')" />
|
||||
</view>
|
||||
|
||||
<view class="moment-content px-3 py-2" @click.stop="handleToMomentDetail(moment)">
|
||||
<mp-html
|
||||
class="evan-markdown"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
:loading-img="markdownConfig.loadingGif"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
:content="moment.spec.newHtml || ''"
|
||||
:markdown="true"
|
||||
:show-line-number="true"
|
||||
:show-language-name="true"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(社交信息流:着色昵称 + 朋友圈式不缩进正文 + 媒体九宫格 + 内嵌互动脚注) -->
|
||||
<view v-for="(moment, mIndex) in dataList" :key="moment.metadata.name"
|
||||
class="moment-card uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white">
|
||||
<!-- 作者 -->
|
||||
<view class="box-border flex items-center px-4 pt-4">
|
||||
<view class="flex-1 flex items-center">
|
||||
<image class="avatar h-[72rpx] w-[72rpx] shrink-0 rounded-full"
|
||||
:src="checkAvatarUrl(moment.owner?.avatar || bloggerInfo.avatar)"
|
||||
mode="aspectFill" />
|
||||
<view class="ml-3 flex flex-col">
|
||||
<view class="text-sm text-gray-900 font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="mt-0.5 text-xs text-gray-400">
|
||||
{{ formatMomentTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="shrink-0">
|
||||
<uh-button custom-class="!py-1 bg-secondary font-semibold">详情</uh-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 图片 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0" class="images flex flex-wrap items-start px-3 pb-6" :class="`images-${moment.images.length}`">
|
||||
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex" class="image-item box-border p-1" :class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
|
||||
<image
|
||||
mode="aspectFill"
|
||||
class="image-src h-full w-full rounded-lg"
|
||||
:src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 正文-->
|
||||
<view class="moment-content px-4 pt-3 ">
|
||||
|
||||
<!-- 音频 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0" class="audio-list mb-3 flex flex-col gap-3 px-3">
|
||||
<uh-audio-player
|
||||
v-for="audio in moment.audios"
|
||||
:key="audio.url"
|
||||
:src="audio.url"
|
||||
:poster="bloggerInfo.avatar"
|
||||
:name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0"
|
||||
class="mb-3 flex flex-wrap gap-x-2">
|
||||
<text v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex"
|
||||
class="py-1 px-2 text-xs rounded-xl bg-secondary">
|
||||
# {{ tag }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0" class="video-list mb-3 flex flex-col gap-3 px-3">
|
||||
<video
|
||||
v-for="(video, index) in moment.videos"
|
||||
:id="`video_${video.id}`"
|
||||
:key="index"
|
||||
class="video-src h-[400rpx] w-full rounded-xl"
|
||||
:src="video.url"
|
||||
:show-mute-btn="true"
|
||||
:controls="true"
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
@play="onVideoPlay(video.id || '')"
|
||||
@pause="onVideoPause(video.id || '')"
|
||||
@ended="onVideoEnded"
|
||||
/>
|
||||
</view>
|
||||
<mp-html lazy-load :domain="markdownConfig.domain ?? ''"
|
||||
:loading-img="markdownConfig.loadingGif" scroll-table selectable
|
||||
:tag-style="markdownConfig.tagStyle" :container-style="markdownConfig.containStyle"
|
||||
:content="moment.spec.newHtml || ''" :markdown="true" :show-line-number="true"
|
||||
:show-language-name="true" copy-by-long-press
|
||||
@click.stop="handleToMomentDetail(moment)" />
|
||||
</view>
|
||||
|
||||
<!-- 标签 -->
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-4 px-3 pb-6">
|
||||
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="tag text-[24rpx]" :style="{ color: randomTagColor() }">
|
||||
{{ tag }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- 图片 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0"
|
||||
class="images flex flex-wrap items-start px-4 pt-3">
|
||||
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex"
|
||||
class="image-item box-border p-1"
|
||||
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
|
||||
<image mode="aspectFill" class="image-src h-full w-full rounded-lg" :src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 互动数据(点赞/评论) -->
|
||||
<view class="flex items-center justify-end gap-7 px-4 pb-4 text-[24rpx] text-[#8a919e]">
|
||||
<view class="flex items-center gap-1">
|
||||
<wd-icon name="heart" size="14px" color="#f08585" />
|
||||
<text>{{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-1">
|
||||
<wd-icon name="message" size="14px" color="#9aa3b2" />
|
||||
<text>{{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 音频 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0"
|
||||
class="audio-list flex flex-col gap-3 px-4 pt-3">
|
||||
<uh-audio-player v-for="audio in moment.audios" :key="audio.url" :src="audio.url"
|
||||
:poster="bloggerInfo.avatar" :name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname" />
|
||||
</view>
|
||||
|
||||
<view class="fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full moment-glass" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
</view>
|
||||
<view class="load-text pb-5 text-center text-[24rpx] text-[#999]">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0"
|
||||
class="video-list flex flex-col gap-3 px-4 pt-3">
|
||||
<video v-for="(video, index) in moment.videos" :id="`video_${video.id}`" :key="index"
|
||||
class="video-src h-[400rpx] w-full rounded-xl" :src="video.url" :show-mute-btn="true"
|
||||
:controls="true" :show-center-play-btn="true" :enable-progress-gesture="true"
|
||||
@play="onVideoPlay(video.id || '')" @pause="onVideoPause(video.id || '')"
|
||||
@ended="onVideoEnded" />
|
||||
</view>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 苹果风玻璃拟态试验(测试点:瞬间页)
|
||||
* 原理:页面固定一层多彩渐变"壁纸",卡片用半透明白 + backdrop-filter,
|
||||
* 壁纸的颜色透过玻璃才看得见(纯白背景看不出毛玻璃)。
|
||||
*/
|
||||
.moments-page {
|
||||
/* 兜底底色(壁纸固定层异常时页面不至于纯白) */
|
||||
background-color: #eef1fd;
|
||||
}
|
||||
<!-- (点赞/评论) -->
|
||||
<view
|
||||
class="mt-3 mb-1 box-border w-full flex items-center justify-center gap-x-12 border-t border-black/5 py-3 text-xs text-gray-400">
|
||||
<view class="flex items-center gap-x-2">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-kiss-" size="32rpx" />
|
||||
<text class="text-sm text-gray-600">点赞 {{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-x-2">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
|
||||
<text class="text-sm text-gray-600">评论 {{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
.moments-wallpaper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
/* 通栏渐变铺满整屏(随视口固定):顶部白衔接导航栏,中段淡蓝紫,底部淡粉回环 */
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#ffffff 0%,
|
||||
#f3f6ff 20%,
|
||||
#edf0ff 46%,
|
||||
#f6eeff 68%,
|
||||
#ffeef6 88%,
|
||||
#f4f7ff 100%
|
||||
);
|
||||
}
|
||||
</view>
|
||||
|
||||
/* 柔光光斑:以软径向渐变直接呈现"虚化"质感(免 filter blur,低端机零开销),
|
||||
* 分布覆盖整屏,让玻璃卡片在任何位置都有色可"取" */
|
||||
.deco {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60rpx);
|
||||
}
|
||||
|
||||
.deco-blue {
|
||||
width: 64%;
|
||||
height: 64%;
|
||||
right: -18%;
|
||||
top: -14%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 85%) 0%, rgb(124 163 255 / 42%) 22%, rgb(96 140 255 / 30%) 42%, transparent 68%);
|
||||
}
|
||||
|
||||
.deco-pink {
|
||||
width: 48%;
|
||||
height: 48%;
|
||||
left: -14%;
|
||||
top: 16%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 80%) 0%, rgb(255 122 176 / 32%) 26%, rgb(255 110 160 / 20%) 48%, transparent 72%);
|
||||
}
|
||||
|
||||
.deco-lavender {
|
||||
width: 54%;
|
||||
height: 54%;
|
||||
right: -10%;
|
||||
top: 42%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 75%) 0%, rgb(170 132 255 / 28%) 30%, rgb(158 120 255 / 18%) 50%, transparent 72%);
|
||||
}
|
||||
|
||||
.deco-cyan {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
left: -16%;
|
||||
bottom: -18%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 70%) 0%, rgb(90 216 236 / 24%) 30%, rgb(70 200 226 / 16%) 52%, transparent 72%);
|
||||
}
|
||||
|
||||
/* 中部柔和提亮,避免大面积素色发闷 */
|
||||
.deco-lift {
|
||||
width: 42%;
|
||||
height: 42%;
|
||||
left: 28%;
|
||||
bottom: 6%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 55%), transparent 70%);
|
||||
}
|
||||
|
||||
.moment-glass {
|
||||
background-color: rgb(255 255 255 / 55%);
|
||||
border: 1rpx solid rgb(255 255 255 / 65%);
|
||||
box-shadow:
|
||||
inset 0 1rpx 0 rgb(255 255 255 / 75%),
|
||||
0 8rpx 32rpx rgb(90 105 200 / 14%);
|
||||
backdrop-filter: blur(24rpx) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(160%);
|
||||
|
||||
/* 低端安卓 WebView 不支持 backdrop-filter 的兜底:提高不透明度保证可读性 */
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
background-color: rgb(255 255 255 / 88%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<view class="load-text pb-5 pt-1 text-center text-xs text-gray-500">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
@@ -0,0 +1,423 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 瞬间页(源自旧项目 pages/tabbar/moments/moments.vue,新建复刻)
|
||||
* 功能:瞬间卡片列表(头像/内容/图片/音频/视频/标签) + 分页加载
|
||||
* 设计:固定壁纸光斑层为卡片毛玻璃取色(苹果风玻璃拟态)
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色,壁纸光斑由页面内提供
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
}
|
||||
})
|
||||
|
||||
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
|
||||
const siteName = computed(() => {
|
||||
const appInfo = haloConfigs.value.appConfig?.appInfo as { name?: string } | undefined
|
||||
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
|
||||
})
|
||||
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
|
||||
type MomentCard = IMoment & {
|
||||
images?: { type?: string, url: string }[]
|
||||
videos?: { id?: string, url: string }[]
|
||||
audios?: { type?: string, url: string }[]
|
||||
spec: IMoment['spec'] & { newHtml?: string }
|
||||
}
|
||||
const dataList = ref<MomentCard[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
|
||||
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
|
||||
const calcTagColors = computed(() => {
|
||||
return dataList.value.map(moment =>
|
||||
(moment.spec.tags || []).map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f')),
|
||||
)
|
||||
})
|
||||
|
||||
/** 移除内容中的 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString: string): string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
|
||||
/** 瞬间项映射(spec.content.medium 拆分为 images/videos/audios + 内容 tag 清理 + 作者兜底) */
|
||||
function mapMomentItem(item: IMoment): MomentCard {
|
||||
const medium = (item.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = item.owner
|
||||
return {
|
||||
...item,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...item.spec,
|
||||
newHtml: removeTagLinksCompletely(item.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
// 审核模式:真实瞬间按 audit-data moments 过滤(数组顺序即展示顺序)
|
||||
const auditMomentNames = appConfigStore.auditData.spec?.moments || []
|
||||
try {
|
||||
const res = await getMomentList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC' && auditMomentNames.includes(x.metadata.name))
|
||||
const orderMap = new Map(auditMomentNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
const tempItems = filtered.map(mapMomentItem)
|
||||
dataList.value = tempItems
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
try {
|
||||
const res = await getMomentList({ ...queryParams.value })
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
|
||||
const tempItems = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC')
|
||||
.map(mapMomentItem)
|
||||
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(list: { videos?: { id?: string }[] }[]) {
|
||||
stopAllVideos()
|
||||
list.map(item => item.videos || []).flat().forEach((item) => {
|
||||
if (item.id) {
|
||||
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stopAllVideos(excludesVideoId: string | null = null) {
|
||||
Object.keys(videoContexts.value).forEach((videoId) => {
|
||||
if (!excludesVideoId || excludesVideoId !== videoId) {
|
||||
videoContexts.value[videoId]?.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVideoPlay(videoId: string) {
|
||||
currentVideoId.value = videoId
|
||||
stopAllVideos(videoId)
|
||||
}
|
||||
|
||||
function onVideoPause(videoId: string) {
|
||||
if (currentVideoId.value === videoId) {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoEnded() {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handlePreview(index: number, list: { url: string }[]) {
|
||||
uni.previewImage({
|
||||
current: index,
|
||||
urls: list.map(item => item.url),
|
||||
})
|
||||
}
|
||||
|
||||
function handleToMomentDetail(moment: IMoment) {
|
||||
if (calcAuditModeEnabled.value)
|
||||
return
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time?: string): string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="relative box-border min-h-screen w-screen flex flex-col bg-page py-6">
|
||||
<!-- 壁纸(柔和光斑为卡片毛玻璃取色,固定不随滚动) -->
|
||||
<view class="pointer-events-none fixed inset-0 z-0 overflow-hidden">
|
||||
<view class="absolute h-[420rpx] w-[420rpx] rounded-full bg-[rgba(103,164,242,0.15)] -left-[120rpx] -top-[60rpx]" />
|
||||
<view class="absolute top-[260rpx] h-[360rpx] w-[360rpx] rounded-full bg-[rgba(244,143,177,0.14)] -right-[130rpx]" />
|
||||
<view class="absolute top-[700rpx] h-[400rpx] w-[400rpx] rounded-full bg-[rgba(179,157,219,0.13)] -left-[150rpx]" />
|
||||
<view class="absolute top-[1120rpx] h-[380rpx] w-[380rpx] rounded-full bg-[rgba(77,208,235,0.12)] -right-[110rpx]" />
|
||||
<view class="absolute left-[180rpx] top-[1560rpx] h-[420rpx] w-[420rpx] rounded-full bg-[rgba(185,228,36,0.14)]" />
|
||||
</view>
|
||||
|
||||
<uh-plugin-unavailable
|
||||
v-if="!uniHaloPluginAvailable"
|
||||
:plugin-id="uniHaloPluginId"
|
||||
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员"
|
||||
@on-refresh="handleGetData"
|
||||
/>
|
||||
<template v-else>
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading === 'loading'" class="loading-wrap p-3">
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</view>
|
||||
|
||||
<!-- 加载失败(可重试) -->
|
||||
<uh-data-loading
|
||||
v-else-if="loading === 'error'"
|
||||
:loading-status="loading"
|
||||
min-height="60vh"
|
||||
error-text="瞬间加载失败,请点击重试"
|
||||
@refresh="handleGetData"
|
||||
/>
|
||||
|
||||
<view v-else class="relative z-1 flex flex-col gap-y-4 p-4">
|
||||
<view v-if="dataList.length === 0" class="min-h-[70vh] w-full flex items-center justify-center content-empty">
|
||||
<wd-empty :description="t('common.empty')" />
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(玻璃) -->
|
||||
<view v-for="(moment, mIndex) in dataList" :key="moment.metadata.name" class="uh-global-card-glass flex flex-col overflow-hidden rounded-[32rpx]">
|
||||
<view class="head flex items-center p-3 pb-0">
|
||||
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 border-2 border-white/80 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="nickname ml-3">
|
||||
<view class="nickname-text text-[30rpx] text-gray-900 font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="release-time mt-1 text-[24rpx] text-gray-400">
|
||||
{{ formatMomentTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="moment-content px-3 py-2" @click.stop="handleToMomentDetail(moment)">
|
||||
<mp-html
|
||||
class="evan-markdown"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
:loading-img="markdownConfig.loadingGif"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
:content="moment.spec.newHtml || ''"
|
||||
:markdown="true"
|
||||
:show-line-number="true"
|
||||
:show-language-name="true"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 图片 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0" class="images flex flex-wrap items-start px-3 pb-4">
|
||||
<view
|
||||
v-for="(image, mediumIndex) in moment.images"
|
||||
:key="mediumIndex"
|
||||
class="image-item box-border p-1"
|
||||
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')"
|
||||
>
|
||||
<image
|
||||
mode="aspectFill"
|
||||
class="image-src h-full w-full rounded-lg"
|
||||
:src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 音频 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0" class="audio-list mb-3 flex flex-col gap-3 px-3">
|
||||
<uh-audio-player
|
||||
v-for="audio in moment.audios"
|
||||
:key="audio.url"
|
||||
:src="audio.url"
|
||||
:poster="bloggerInfo.avatar"
|
||||
:name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0" class="video-list mb-3 flex flex-col gap-3 px-3">
|
||||
<video
|
||||
v-for="(video, index) in moment.videos"
|
||||
:id="`video_${video.id}`"
|
||||
:key="index"
|
||||
class="video-src h-[400rpx] w-full rounded-xl"
|
||||
:src="video.url"
|
||||
:show-mute-btn="true"
|
||||
:controls="true"
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
@play="onVideoPlay(video.id || '')"
|
||||
@pause="onVideoPause(video.id || '')"
|
||||
@ended="onVideoEnded"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 标签 -->
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-2 px-3 pb-4 pt-1">
|
||||
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="rounded-full bg-black/5 px-3 py-1 text-[22rpx] font-bold" :style="{ color: calcTagColors[mIndex]?.[tagIndex] }">
|
||||
# {{ tag }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 互动数据(点赞/评论) -->
|
||||
<view class="flex items-center justify-end gap-2 px-4 pb-4">
|
||||
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
|
||||
<wd-icon name="heart" size="12px" color="#f08585" />
|
||||
<text>{{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
|
||||
<wd-icon name="message" size="12px" color="#7f8ea3" />
|
||||
<text>{{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="to-top-btn uh-global-card-glass fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
<view class="load-text pb-5 text-center text-[24rpx] text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
Reference in New Issue
Block a user