mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-13 00:50:40 +08:00
feat: 新增收藏功能与多项体验优化
- 新增本地收藏store与工具函数,支持文章/瞬间收藏持久化 - 重构插件可用性检查hook,统一管理依赖插件状态 - 替换旧数据加载hook为新的状态管理方案 - 优化首页布局与加载逻辑,调整公告组件样式 - 为瞬间详情页添加点赞、评论与收藏功能 - 新增文本处理工具,支持HTML/Markdown转纯文本与摘要截断 - 修复评论弹窗适配瞬间类型的参数问题
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkUrl } from '@/utils/url'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IPublicMaintenance } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
@@ -23,6 +22,8 @@
|
||||
const RECOVERY_POLL_INTERVAL = 30 * 1000
|
||||
|
||||
const store = useAppConfigStore()
|
||||
/** 插件可用性(拦截恢复检测用) */
|
||||
const { check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
|
||||
|
||||
const viewState = ref<ViewState>('loading')
|
||||
const maintenance = ref<IPublicMaintenance | null>(null)
|
||||
@@ -146,7 +147,7 @@
|
||||
try {
|
||||
await store.bootstrap({ force: true })
|
||||
if (fromReason.value === 'plugin') {
|
||||
const available = await usePluginAvailable(uniHaloPluginId)
|
||||
const available = await checkPluginAvailable()
|
||||
if (!available) {
|
||||
const info = store.configs.maintenance
|
||||
if (info) {
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title + 彩色图标块)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||||
import { getBlogStatistics } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useFavoritesStore } from '@/store/favorites'
|
||||
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({
|
||||
@@ -23,10 +23,13 @@ definePage({
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const favoritesStore = useFavoritesStore()
|
||||
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 { check: checkDataVisualPlugin } = usePluginAvailable('plugin-data-statistics')
|
||||
|
||||
/* ---------------- 计算属性 ---------------- */
|
||||
const bloggerInfo = computed(() => {
|
||||
@@ -116,10 +119,28 @@ function toSolidColor(rgba: string) {
|
||||
return rgba.replace('0.95)', '1)')
|
||||
}
|
||||
|
||||
/** 收藏导航项右侧文案跟随收藏总数(收藏页返回/切回时刷新) */
|
||||
function syncFavoritesNavText() {
|
||||
const nav = navList.value.find(n => n.key === 'favorites')
|
||||
if (nav) {
|
||||
nav.rightText = `共 ${favoritesStore.counts.total} 条收藏`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetNavList() {
|
||||
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
|
||||
const dataVisualAvailable = await checkDataVisualPlugin()
|
||||
|
||||
navList.value = [
|
||||
{
|
||||
key: 'favorites',
|
||||
title: '我的收藏',
|
||||
icon: 'star',
|
||||
bgColor: 'rgba(255, 179, 0, 0.95)',
|
||||
rightText: '',
|
||||
path: '/pages-blog/favorites/favorites',
|
||||
show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'data-visual',
|
||||
title: '数据看板',
|
||||
@@ -217,6 +238,7 @@ async function handleGetNavList() {
|
||||
group: 'more',
|
||||
},
|
||||
]
|
||||
syncFavoritesNavText()
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
@@ -269,6 +291,11 @@ watch(haloConfigs, () => {
|
||||
|
||||
handleGetData()
|
||||
|
||||
// 从收藏页返回/切回时刷新收藏数文案
|
||||
onShow(() => {
|
||||
syncFavoritesNavText()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { IPhoto, IPhotoGroup } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
@@ -27,10 +27,10 @@
|
||||
|
||||
/** 依赖插件(plugin-photos) */
|
||||
const uniHaloPluginId = 'plugin-photos'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId, false)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error' | 'empty'>('loading')
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const category = ref<{ activeIndex : number, list : IPhotoGroup[] }>({
|
||||
activeIndex: 0,
|
||||
list: [],
|
||||
@@ -58,14 +58,12 @@
|
||||
handleGetData(true)
|
||||
}
|
||||
else {
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
loading.value = 'error'
|
||||
category.value = { activeIndex: 0, list: [] }
|
||||
}
|
||||
return
|
||||
@@ -82,7 +80,6 @@
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
loading.value = 'error'
|
||||
category.value = { activeIndex: 0, list: [] }
|
||||
}
|
||||
}
|
||||
@@ -94,7 +91,7 @@
|
||||
}
|
||||
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = ''
|
||||
|
||||
@@ -110,12 +107,12 @@
|
||||
? dataList.value.concat(list)
|
||||
: list
|
||||
}
|
||||
loading.value = dataList.value.length !== 0 ? 'success' : 'empty'
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
@@ -150,20 +147,18 @@
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
// 检查插件可用性
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
await checkPluginAvailable()
|
||||
console.log('uniHaloPluginAvailable',uniHaloPluginAvailable.value)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
watch(galleryConfig, (newVal) => {
|
||||
if (!newVal)
|
||||
return
|
||||
uni.setNavigationBarTitle({ title: newVal.pageTitle || t('page.gallery.title') })
|
||||
|
||||
|
||||
// 开始正常数据请求
|
||||
handleGetCategory()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
@@ -211,9 +206,8 @@
|
||||
</wd-sticky>
|
||||
|
||||
<!-- 加载/错误占位 -->
|
||||
<view v-if="loading !== 'success'" class="box-border p-3">
|
||||
<uh-data-loading :loading-status="loading" @refresh="handleGetCategory" />
|
||||
</view>
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
@refresh="handleGetCategory" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view v-else class="box-border w-full p-3">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import { useMaintenanceIntercept } from '@/hooks/useMaintenanceIntercept'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { IPost } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
@@ -28,7 +29,7 @@
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const articleList = ref<IPost[]>([])
|
||||
@@ -79,12 +80,12 @@
|
||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
||||
return item
|
||||
})
|
||||
loading.value = 'success'
|
||||
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = t('common.noMore')
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取审核文章失败', err)
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
@@ -95,7 +96,7 @@
|
||||
}
|
||||
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
@@ -108,11 +109,11 @@
|
||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
||||
return item
|
||||
})
|
||||
loading.value = 'success'
|
||||
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
}
|
||||
catch (err) {
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
console.error('获取文章失败', err)
|
||||
}
|
||||
@@ -147,14 +148,14 @@
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function init(){
|
||||
|
||||
function init() {
|
||||
if (!intercepted.value) {
|
||||
handleQuery()
|
||||
}
|
||||
}
|
||||
init()
|
||||
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
|
||||
// 维护检查
|
||||
@@ -183,55 +184,48 @@
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="min-h-screen w-screen flex flex-col bg-page">
|
||||
<!-- 轮播 -->
|
||||
<uh-home-banner />
|
||||
|
||||
<!-- 公告 -->
|
||||
<uh-home-notify />
|
||||
|
||||
<!-- 快捷导航 -->
|
||||
<uh-home-quick-nav />
|
||||
|
||||
<!-- 精选分类 -->
|
||||
<uh-home-category />
|
||||
|
||||
<!-- 最新文章 -->
|
||||
<uh-section-title class="mb-4 box-border px-3">
|
||||
最新内容
|
||||
<template #right>
|
||||
<view class="uh-global-card-glass flex items-center justify-center rounded-md p-1 text-gray-400"
|
||||
@click="handleToSearch()">
|
||||
<wd-icon name="arrow-right" size="12px" />
|
||||
</view>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
|
||||
<!-- 加载/错误占位 -->
|
||||
<uh-data-loading v-if="loading !== 'success' && articleList.length === 0" :loading-status="loading"
|
||||
@refresh="handleQuery" />
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
min-height="36vh" @refresh="handleQuery" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<block v-else>
|
||||
<!-- 轮播 -->
|
||||
<uh-home-banner />
|
||||
|
||||
<!-- 公告 -->
|
||||
<uh-home-notify />
|
||||
|
||||
<!-- 快捷导航 -->
|
||||
<uh-home-quick-nav />
|
||||
|
||||
<!-- 精选分类 -->
|
||||
<uh-home-category />
|
||||
|
||||
<!-- 最新文章 -->
|
||||
<uh-section-title class="mb-4 box-border px-3">
|
||||
最新内容
|
||||
<template #right>
|
||||
<view class="uh-global-card-glass flex items-center justify-center rounded-md p-1 text-gray-400"
|
||||
@click="handleToSearch()">
|
||||
<wd-icon name="arrow-right" size="12px" />
|
||||
</view>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
|
||||
<view v-if="articleList.length === 0" class="article-empty py-10">
|
||||
<wd-empty description="博主还没有发表任何内容~" />
|
||||
<view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
|
||||
<uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
|
||||
@on-click="handleToArticleDetail" />
|
||||
</view>
|
||||
<view class="load-text mt-3 pb-5 text-center text-xs text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
<view v-if="articleList.length > 10" class="to-top-btn" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
</view>
|
||||
<block v-else>
|
||||
<view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
|
||||
<uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
|
||||
@on-click="handleToArticleDetail" />
|
||||
</view>
|
||||
<view class="load-text mt-3 pb-5 text-center text-xs text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
<view v-if="articleList.length > 10" class="to-top-btn" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
</view>
|
||||
</block>
|
||||
</block>
|
||||
</view>
|
||||
<uh-notify-dialog />
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useFavoritesStore } from '@/store/favorites'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { buildMomentFavoriteItem } from '@/utils/favorite'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { formatTime } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
@@ -21,12 +23,11 @@
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const favoritesStore = useFavoritesStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
@@ -47,13 +48,13 @@
|
||||
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
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 }[]
|
||||
@@ -66,13 +67,6 @@
|
||||
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
|
||||
@@ -116,14 +110,14 @@
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
@@ -131,13 +125,12 @@
|
||||
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.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
|
||||
|
||||
@@ -148,6 +141,7 @@
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
@@ -155,7 +149,7 @@
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
@@ -216,6 +210,18 @@
|
||||
})
|
||||
}
|
||||
|
||||
/** 是否已收藏该瞬间(卡片收藏格高亮) */
|
||||
function isMomentFavorite(moment : IMoment) : boolean {
|
||||
return favoritesStore.isFavorite('moment', moment.metadata.name)
|
||||
}
|
||||
|
||||
/** 切换收藏(收藏/取消),收藏时按当前卡片内容生成快照入库 */
|
||||
function handleToggleMomentFavorite(moment : MomentCard) {
|
||||
if (!moment) { return }
|
||||
const favorited = favoritesStore.toggle(buildMomentFavoriteItem(moment))
|
||||
uni.showToast({ icon: 'none', title: favorited ? '收藏成功' : '已取消收藏' })
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
@@ -228,14 +234,13 @@
|
||||
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time ?: string) : string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
return time ? formatTime({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
await checkPluginAvailable()
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
@@ -256,8 +261,7 @@
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (!uniHaloPluginAvailable.value) { return }
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
@@ -279,8 +283,8 @@
|
||||
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员" @on-refresh="handleGetData" />
|
||||
<template v-else>
|
||||
<!-- 加载失败(可重试) -->
|
||||
<uh-data-loading v-if="loading !== 'success'" :loading-status="loading" min-height="60vh"
|
||||
@refresh="handleGetData" />
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
min-height="60vh" @refresh="handleGetData" />
|
||||
|
||||
<view v-else class="flex flex-col gap-3 px-3">
|
||||
<view v-if="dataList.length === 0"
|
||||
@@ -289,7 +293,7 @@
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(社交信息流:着色昵称 + 朋友圈式不缩进正文 + 媒体九宫格 + 内嵌互动脚注) -->
|
||||
<!-- 瞬间卡片-->
|
||||
<view v-for="moment in dataList" :key="moment.metadata.name"
|
||||
class="moment-card uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white">
|
||||
<!-- 作者 -->
|
||||
@@ -372,9 +376,10 @@
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
|
||||
<text class="text-sm text-gray-600">评论 {{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-x-1">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
|
||||
<text class="text-sm text-gray-600">收藏</text>
|
||||
<view class="flex items-center gap-x-1" @click.stop="handleToggleMomentFavorite(moment)">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-smile-" size="32rpx" />
|
||||
<text class="text-sm text-gray-600"
|
||||
:style="isMomentFavorite(moment) ? { color: '#ffb300' } : ''">{{ isMomentFavorite(moment) ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
<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