1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-09-13 00:50:40 +08:00

refactor: 移除启动页逻辑,新增全局组件与偏好设置系统

1.  移除旧版启动页分流逻辑,直接跳转首页
2.  新增返回顶部、章节标题、导航栏等全局组件
3.  重构偏好设置系统,实现站点默认与本地差异分层管理
4.  删除冗余的测试mock、插件模块与样式文件
5.  优化文章卡片与评论组件样式,更新全局主题色
6.  清理废弃的请求参数与配置项
This commit is contained in:
小莫唐尼
2026-09-03 22:36:26 +08:00
parent cf9c02cfc3
commit 902faa70e7
49 changed files with 3170 additions and 4288 deletions
+8 -32
View File
@@ -7,6 +7,8 @@ import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getQRCodeInfo } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { collectSiteDefaults } from '@/utils/preference'
import { usePluginAvailable } from '@/utils/plugin'
definePage({
@@ -21,7 +23,6 @@ definePage({
/* ---------------- 常量 ---------------- */
const homePagePath = '/pages/tabbar/home/home'
const startPagePath = '/pages/start/start'
const articleDetailPath = '/pages-blog/article-detail/article-detail'
// 本地开发快速跳转页面,发布请置为 false
@@ -31,6 +32,7 @@ const DEV_TO_PATH = `${articleDetailPath}?name=01a057b2-3200-74af-8afe-28a054092
/* ---------------- 状态 ---------------- */
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const uniHaloPluginId = 'plugin-uni-halo'
const uniHaloPluginAvailableError = '阿偶,检测到当前插件没有安装或者启用,无法启动 uni-halo 哦,请联系管理员'
const uniHaloPluginAvailable = ref(true)
@@ -60,35 +62,6 @@ async function handleAuditMode() {
await appConfigStore.fetchAuditData()
}
/** 启动页/首页分流 */
function handleCheckShowStarted() {
const appConfig = (appConfigStore.configs.appConfig ?? {}) as {
startConfig?: { enabled?: boolean, alwaysShow?: boolean }
}
const startConfig = appConfig.startConfig
// 未开启启动页,直接进首页
if (!startConfig?.enabled) {
uni.switchTab({ url: homePagePath })
return
}
// 是否每次都显示启动页
if (startConfig.alwaysShow) {
uni.removeStorageSync('APP_HAS_STARTED')
uni.redirectTo({ url: startPagePath })
return
}
// 只显示一次启动页
if (uni.getStorageSync('APP_HAS_STARTED')) {
uni.switchTab({ url: homePagePath })
}
else {
uni.redirectTo({ url: startPagePath })
}
}
onLoad(async (options) => {
// 本地开发,快速跳转页面,发布请设置 DEV_MODE = false
if (DEV_MODE && DEV_TO_PATH) {
@@ -128,8 +101,11 @@ onLoad(async (options) => {
// 审计模式数据(公开接口 /audit-data)
await handleAuditMode()
// 启动页分流
handleCheckShowStarted()
// 两层偏好合并:应用站点默认(L0)到 setting store(内部合并本地差异,含旧数据迁移)
settingStore.applySiteDefaults(collectSiteDefaults(appConfigStore.configs))
// 启动页已下线(v2.2 ⑤):直接进首页
uni.switchTab({ url: homePagePath })
}
catch (err) {
console.error('入口页初始化失败', err)
-299
View File
@@ -1,299 +0,0 @@
<script lang="ts" setup>
/**
* 启动页(源自旧项目 pagesA/start,新建复刻)
* 支持颜色/图片/视频/星空四种背景类型 + logo/标题/描述 + 开始按钮 + 波浪
*/
import { computed } from 'vue'
import { checkImageUrl, checkUrl } from '@/utils/url'
import { useAppConfigStore } from '@/store/appConfig'
definePage({
style: {
navigationBarTitleText: 'uni-halo',
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const startConfig = computed(() => (haloConfigs.value.appConfig?.startConfig as {
title?: string
logo?: string
desc1?: string
desc2?: string
btnText?: string
btnClass?: string
btnStyle?: string
titleStyle?: string
descStyle?: string
backgroundType?: string
bg?: string
bgImage?: string
bgImageFit?: string
bgVideo?: string
bgVideoFit?: string
useWave?: boolean
} | undefined) || {})
const calcBackgroundType = computed(() => startConfig.value.backgroundType || 'star')
const calcPageClass = computed(() => {
if (calcBackgroundType.value === 'color') {
return [startConfig.value.bg]
}
return []
})
const calcPageStyle = computed(() => {
if (calcBackgroundType.value === 'color') {
return {}
}
if (calcBackgroundType.value === 'image') {
return {
backgroundImage: `url(${checkImageUrl(startConfig.value.bgImage)}) !important`,
backgroundSize: startConfig.value.bgImageFit || 'cover',
}
}
if (calcBackgroundType.value === 'video') {
return {
background: '#ffffff',
}
}
return {}
})
function handleStart() {
uni.switchTab({
url: '/pages/tabbar/home/home',
success: () => {
uni.setStorageSync('APP_HAS_STARTED', true)
},
})
}
</script>
<template>
<view class="app-page relative h-screen w-screen" :class="calcPageClass" :style="[calcPageStyle]">
<!-- 星空背景 -->
<view v-if="calcBackgroundType !== 'video'" class="star-bg fixed z-998 h-[600px] w-full shrink-0 overflow-hidden">
<view class="stars absolute z-1 h-[400px] w-full">
<view class="falling-stars">
<view class="star-fall" />
<view class="star-fall" />
<view class="star-fall" />
<view class="star-fall" />
</view>
<view class="small-stars">
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
</view>
</view>
</view>
<!-- 视频背景 -->
<video
v-else
class="video-bg absolute left-0 top-0 z-0 h-screen w-screen"
:object-fit="(startConfig.bgVideoFit as 'contain' | 'cover') || 'cover'"
:src="checkUrl(startConfig.bgVideo)"
:loop="true"
:autoplay="true"
:muted="true"
:controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-center-play-btn="false"
:show-loading="false"
:enable-progress-gesture="false"
:show-progress="false"
/>
<!-- 标题区域 -->
<view v-if="startConfig.title || startConfig.logo" class="title-container absolute left-0 top-[20vh] z-999 w-screen flex flex-col items-center justify-center">
<view v-if="startConfig.logo" class="app-logo h-[200rpx] w-[200rpx]">
<view class="app-logo-border box-border h-full w-full overflow-hidden border-8 border-white/35 rounded-full">
<image class="app-logo-image h-full w-full rounded-full" :src="checkImageUrl(startConfig.logo)" mode="aspectFill" />
</view>
</view>
<view v-if="startConfig.title" class="app-title mt-6 text-center text-[36rpx] text-white font-semibold" :style="startConfig.titleStyle">
{{ startConfig.title }}
</view>
</view>
<!-- 底部区域 -->
<view class="bottom-container absolute bottom-[50rpx] left-1/2 z-999 flex flex-col items-center -translate-x-1/2">
<view class="desc-area pt-[60vh] text-white" :style="startConfig.descStyle">
<view v-show="startConfig.desc1" class="desc1 text-center text-[44rpx]">
{{ startConfig.desc1 }}
</view>
<view v-show="startConfig.desc2" class="desc2 mt-8 text-center text-[26rpx]">
{{ startConfig.desc2 }}
</view>
</view>
<view class="start-btn mb-[120rpx] mt-[60rpx] box-border border-2 border-white rounded-[50rpx] px-12 py-4 text-center text-[28rpx] text-white" :class="[startConfig.btnClass]" :style="[startConfig.btnStyle]" @click="handleStart">
{{ startConfig.btnText || '开始体验' }}
</view>
</view>
<!-- 波浪效果 -->
<image v-if="startConfig.useWave" class="wave-img absolute bottom-0 left-0 z-99 h-[100rpx] w-full" src="/static/wave/wave-1.png" mode="scaleToFill" />
</view>
</template>
<style scoped lang="scss">
.app-page {
background-size: cover;
background-repeat: no-repeat;
background: linear-gradient(180deg, #0f1e3d 0%, #1a3a6b 100%);
}
/* 星空背景(动画无法用 UnoCSS 表达,保留样式) */
.star-bg {
.star {
border-radius: 50%;
background: #fff;
box-shadow: 0 0 6px 0 rgb(255 255 255 / 80%);
}
.small-stars .star {
position: absolute;
width: 3px;
height: 3px;
opacity: 0;
animation: star-blink 1.2s linear infinite alternate;
&:nth-child(1) {
left: 40px;
bottom: 50px;
}
&:nth-child(2) {
left: 200px;
bottom: 40px;
}
&:nth-child(3) {
left: 60px;
bottom: 120px;
}
&:nth-child(4) {
left: 140px;
bottom: 250px;
}
&:nth-child(5) {
left: 400px;
bottom: 300px;
}
&:nth-child(6) {
left: 170px;
bottom: 80px;
}
&:nth-child(7) {
left: 200px;
bottom: 360px;
animation-delay: 0.2s;
}
&:nth-child(8) {
left: 250px;
bottom: 320px;
}
&:nth-child(9) {
left: 300px;
bottom: 340px;
}
&:nth-child(10) {
left: 130px;
bottom: 320px;
animation-delay: 0.5s;
}
&:nth-child(11) {
left: 230px;
bottom: 330px;
animation-delay: 0.7s;
}
&:nth-child(12) {
left: 300px;
bottom: 360px;
animation-delay: 0.3s;
}
}
.star-fall {
position: relative;
border-radius: 2px;
width: 80px;
height: 2px;
overflow: hidden;
transform: rotate(-20deg);
&::after {
content: '';
position: absolute;
width: 50px;
height: 2px;
background: linear-gradient(to left, rgb(0 0 0 / 0%) 0%, rgb(255 255 255 / 40%) 100%);
left: 100%;
animation: star-fall 3.6s linear infinite;
}
&:nth-child(1) {
left: 80px;
bottom: -100px;
&::after {
animation-delay: 2.4s;
}
}
&:nth-child(2) {
left: 200px;
bottom: -200px;
&::after {
animation-delay: 2s;
}
}
&:nth-child(3) {
left: 430px;
bottom: -50px;
&::after {
animation-delay: 3.6s;
}
}
&:nth-child(4) {
left: 400px;
bottom: 100px;
&::after {
animation-delay: 0.2s;
}
}
}
}
@keyframes star-blink {
50% {
opacity: 1;
}
}
@keyframes star-fall {
20% {
left: -100%;
}
100% {
left: -100%;
}
}
/* 波浪混合模式(无法用 UnoCSS 表达) */
.wave-img {
mix-blend-mode: screen;
}
</style>
+9
View File
@@ -159,6 +159,15 @@ async function handleGetNavList() {
path: '/pages-blog/about/about',
show: !!basicConfig.value?.showAboutSystem,
},
{
key: 'setting',
title: '偏好设置',
icon: 'settings',
iconColor: '#03a9f4',
rightText: '首页布局、卡片样式等本地偏好',
path: '/pages-blog/setting/setting',
show: true,
},
]
}
+158 -359
View File
@@ -1,384 +1,183 @@
<script lang="ts" setup>
/**
* 分类页(源自旧项目 pages/tabbar/category/category.vue,新建复刻)
* 两种视图:list(分类卡片网格)/ list-post(左侧分类导航 + 右侧文章列表)
*/
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 type { ICategory, IPost } from '@/api/types/halo'
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'
definePage({
style: {
navigationBarTitleText: '分类',
enablePullDownRefresh: true,
},
})
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 loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({
size: 20,
page: 1,
fieldSelector: ['spec.hideFromList=false'],
})
const hasNext = ref(false)
const dataList = ref<ICategory[]>([])
const categoryList = ref<ICategory[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const currentCategoryConfig = ref<{ type?: string }>({ type: 'list' })
const currentCategoryName = ref('')
const postQueryParams = ref({ size: 10, page: 0 })
const postList = ref<IPost[]>([])
/* ---------------- 状态 ---------------- */
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 calcShowType = computed(() => currentCategoryConfig.value.type)
function handleResetInit() {
dataList.value = []
queryParams.value.page = 1
hasNext.value = false
isLoadMore.value = false
loadMoreText.value = t('common.loading')
}
/* ---------------- 视图切换 ---------------- */
function handleChangeShowType() {
currentCategoryConfig.value.type = calcShowType.value === 'list-post' ? 'list' : 'list-post'
handleInitPage()
}
function handleInitPage() {
handleResetInit()
handleGetData()
}
function handleResetInit() {
postList.value = []
dataList.value = []
categoryList.value = []
queryParams.value.page = 1
postQueryParams.value.page = 0
hasNext.value = false
isLoadMore.value = false
loadMoreText.value = t('common.loading')
currentCategoryName.value = ''
}
/* ---------------- 数据加载 ---------------- */
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
}
function handleInitPage() {
handleResetInit()
if (calcShowType.value === 'list-post') {
queryParams.value.size = 99999
}
handleGetData()
}
if (!isLoadMore.value) {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
}
loadMoreText.value = t('common.loading')
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
// 审核模式:真实分类按 audit-data categories 过滤(数组顺序即展示顺序)
currentCategoryConfig.value.type = 'list'
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
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 getCategoryList({ ...queryParams.value })
uni.showLoading({ mask: true, title: t('common.loading') })
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
updateLoadingStatus(DataLoadingStatusEnum.Success)
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext
try {
const res = await getCategoryList({ ...queryParams.value })
const tempItems = res.data.items.map(item => ({
...item,
postCount: item.postCount ?? 0,
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
}))
if (calcShowType.value === 'list') {
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.map(item => ({
...item,
postCount: item.postCount ?? 0,
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
}))
}
catch (err) {
console.error(err)
updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
dataList.value = isLoadMore.value
? dataList.value.concat(tempItems)
: tempItems
}
else {
dataList.value = res.data.items
categoryList.value = res.data.items.map(item => ({
...item,
postCount: item.postCount ?? 0,
}))
loading.value = 'success'
if (dataList.value.length !== 0) {
currentCategoryName.value = dataList.value[0].metadata.name
handleGetPostByCategory()
}
}
}
catch (err) {
console.error(err)
loading.value = '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}`,
})
}
/** 获取当前分类下的文章 */
async function handleGetPostByCategory(isPulldownRefresh = true) {
if (!isPulldownRefresh) {
if (hasNext.value) {
postQueryParams.value.page += 1
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
}
else {
postQueryParams.value.page = 0
}
try {
const res = await getCategoryPostList(currentCategoryName.value, postQueryParams.value)
hasNext.value = res.data.hasNext
postList.value = isPulldownRefresh
? res.data.items
: postList.value.concat(res.data.items)
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
catch (err) {
loadMoreText.value = t('common.loadFailedShort')
console.error(err)
}
}
/* ---------------- 生命周期 ---------------- */
/* ---------------- 交互 ---------------- */
function handleOnCategoryChange(e: { detail: { current: number } }) {
const index = e.detail.current
if (!dataList.value[index])
return
currentCategoryName.value = dataList.value[index].metadata.name
postList.value = []
handleGetPostByCategory()
}
onMounted(() => {
handleInitPage()
})
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}`,
})
}
onPullDownRefresh(() => {
handleResetInit()
handleGetData()
})
function handleToArticleDetail(post: IPost) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${post.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleScrollTop() {
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
}
/* ---------------- 生命周期 ---------------- */
watch(categoryConfig, (newVal) => {
if (!newVal)
return
currentCategoryConfig.value = newVal
uni.setNavigationBarTitle({ title: t('page.category.title') })
handleInitPage()
}, { deep: true, immediate: true })
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 1
handleGetData()
})
onReachBottom(() => {
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (hasNext.value) {
if (calcShowType.value === 'list') {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
postQueryParams.value.page += 1
handleGetPostByCategory(false)
}
}
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="app-page min-h-screen w-screen flex flex-col" :style="{ padding: calcShowType === 'list-post' ? '0' : '24rpx 0' }">
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap px-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<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 v-else class="app-page-content flex flex-wrap gap-y-5 px-1.5" :class="[calcShowType === 'list-post' ? 'list-post' : '']">
<view v-if="dataList.length === 0" class="h-[70vh] flex items-center justify-center content-empty">
<wd-empty :description="t('common.empty')" />
</view>
<block v-else>
<!-- list 视图:分类卡片网格 -->
<block v-if="calcAuditModeEnabled || calcShowType === 'list'">
<view
v-for="(item, index) in dataList"
:key="index"
class="catgory-card box-border w-1/2 p-1"
:style="{ backgroundImage: `url(${item.spec.cover})` }"
>
<view class="catgory-card-content h-[200rpx] flex flex-col items-center justify-center overflow-hidden rounded-xl shadow-sm" @click="handleToCategory(item)">
<view class="catgory-name z-2 text-[32rpx] text-white">
{{ item.spec.displayName }}
</view>
<view v-if="!calcAuditModeEnabled" class="catgory-count z-2 mt-1 text-[24rpx] text-white">
{{ item.postCount }} 篇文章
</view>
</view>
</view>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
<!-- list-post 视图:左侧分类 + 右侧文章 -->
<view v-else class="list-post-wrapper min-h-screen w-screen flex">
<scroll-view class="left-nav w-[180rpx] shrink-0 bg-white" :scroll-y="true">
<view
v-for="(item, index) in categoryList"
:key="item.metadata.name"
class="left-nav-item border-l-4 px-4 py-8 text-center text-[26rpx] text-[#606266]"
:class="{ active: currentCategoryName === item.metadata.name }"
@click="handleOnCategoryChange({ detail: { current: index } })"
>
{{ item.spec.displayName }}
</view>
</scroll-view>
<scroll-view class="right-content box-border h-screen flex-1" :scroll-y="true">
<view v-if="postList.length === 0" class="article-empty flex items-center justify-center py-10">
<wd-empty description="该分类下暂无文章~" />
</view>
<block v-else>
<uh-article-min-card
v-for="(post, index) in postList"
:key="index"
:article="post"
@on-click="handleToArticleDetail"
/>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
</scroll-view>
</view>
</block>
</view>
<!-- 悬浮按钮 -->
<view class="flot-buttons fixed bottom-[100rpx] right-8 z-999 flex flex-col gap-1.5">
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleScrollTop">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view v-if="!calcAuditModeEnabled" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleChangeShowType">
<wd-icon :name="calcShowType === 'list' ? 'list' : 'grid'" size="20px" color="#03a9f4" />
</view>
</view>
</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>
</template>
<style scoped lang="scss">
.app-page {
width: 100vw;
}
.app-page-content {
&.list-post {
padding: 0;
gap: 0;
}
}
.catgory-card {
> view {
position: relative;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.catgory-card-content::before {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgb(0 0 0 / 15%);
backdrop-filter: blur(3rpx);
z-index: 1;
}
}
.list-post-wrapper {
.left-nav {
.left-nav-item {
border-left-color: transparent;
&.active {
color: #03a9f4;
border-left-color: #03a9f4;
background-color: #f5f7fa;
font-weight: bold;
}
}
}
}
.flot-buttons {
.fab-btn {
box-shadow: 0 4rpx 16rpx rgb(0 0 0 / 10%);
}
}
</style>
+224 -266
View File
@@ -1,292 +1,250 @@
<script lang="ts" setup>
/**
/**
* 图库页(源自旧项目 pages/tabbar/gallery/gallery.vue,新建复刻)
* 功能:相册分组切换 + 图片列表(瀑布流/网格) + 图片预览
*/
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getPhotoGroupList, getPhotoListByGroupName } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import type { IPhoto, IPhotoGroup } from '@/api/types/halo'
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getPhotoGroupList, getPhotoListByGroupName } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import type { ICategory, IPhoto, IPhotoGroup } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '图库',
enablePullDownRefresh: true,
},
})
definePage({
style: {
navigationBarTitleText: '图库',
enablePullDownRefresh: true,
},
})
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 galleryConfig = computed(() => haloConfigs.value.pageConfig?.galleryConfig)
const galleryConfig = computed(() => haloConfigs.value.pageConfig?.galleryConfig)
/** 依赖插件(plugin-photos) */
const uniHaloPluginId = 'plugin-photos'
const uniHaloPluginAvailable = ref(true)
/** 依赖插件(plugin-photos) */
const uniHaloPluginId = 'plugin-photos'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const category = ref<{ activeIndex: number, list: { name?: string, displayName: string, priority: number }[] }>({
activeIndex: 0,
list: [],
})
const queryParams = ref({ size: 10, page: 1, group: '' })
const isLoadMore = ref(false)
const loadMoreText = ref('')
const hasNext = ref(false)
const dataList = ref<IPhoto[]>([])
const lock = ref(false)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const category = ref<{ activeIndex : number, list : IPhotoGroup[] }>({
activeIndex: 0,
list: [],
})
const queryParams = ref({ size: 10, page: 1, group: '' })
const isLoadMore = ref(false)
const loadMoreText = ref('')
const hasNext = ref(false)
const dataList = ref<IPhoto[]>([])
const lock = ref(false)
/* ---------------- 数据加载 ---------------- */
async function handleGetCategory() {
if (calcAuditModeEnabled.value) {
// 审核模式:仅展示所选图库分组(galleryGroups)内的照片,未分组照片不展示
const auditGroupNames = appConfigStore.auditData.spec?.galleryGroups || []
try {
const res = await getPhotoGroupList({ page: 1, size: 99999 })
const filtered = ((res.data as unknown as IPhotoGroup[] | undefined) || [])
.filter(item => auditGroupNames.includes(item.metadata.name))
.map(item => ({
name: item.metadata.name,
displayName: item.spec.displayName,
priority: item.spec.priority ?? 0,
}))
.sort((a, b) => a.priority - b.priority)
category.value.list = filtered
if (category.value.list.length !== 0) {
queryParams.value.group = category.value.list[0].name || ''
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
}
try {
const res = await getPhotoGroupList({ page: 1, size: 0 })
category.value.list = ((res.data as unknown as IPhotoGroup[] | undefined) || [])
.map(item => ({
name: item.metadata.name,
displayName: item.spec.displayName,
priority: item.spec.priority ?? 0,
}))
.sort((a, b) => a.priority - b.priority)
category.value.list.unshift({ name: undefined, displayName: '全部', priority: 0 })
if (category.value.list.length !== 0) {
queryParams.value.group = category.value.list[0].name || ''
handleGetData(true)
}
}
catch (e) {
console.error(e)
loading.value = 'error'
category.value = { activeIndex: 0, list: [] }
}
}
/* ---------------- 数据加载 ---------------- */
async function handleGetCategory() {
if (calcAuditModeEnabled.value) {
// 审核模式:仅展示所选图库分组(galleryGroups)内的照片,未分组照片不展示
const auditGroupNames = appConfigStore.auditData.spec?.galleryGroups || []
try {
const res = await getPhotoGroupList({ page: 1, size: 0 })
const filtered = ((res.data as unknown as IPhotoGroup[] | undefined) || [])
.filter(item => auditGroupNames.includes(item.metadata.name))
.sort((a, b) => a.spec.priority - b.spec.priority)
category.value.list = filtered
if (category.value.list.length !== 0) {
queryParams.value.group = category.value.list[0].metadata.name || ''
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
}
try {
const res = await getPhotoGroupList({ page: 1, size: 0 })
category.value.list = ((res.data as unknown as IPhotoGroup[] | undefined) || [])
.sort((a, b) => a.spec.priority - b.spec.priority)
category.value.list.unshift({ metadata: { name: undefined }, spec: { displayName: '全部', priority: 0 } })
if (category.value.list.length !== 0) {
queryParams.value.group = category.value.list[0].metadata.name || ''
handleGetData(true)
}
}
catch (e) {
console.error(e)
loading.value = 'error'
category.value = { activeIndex: 0, list: [] }
}
}
async function handleGetData(isClearList = false) {
if (isClearList) {
dataList.value = []
queryParams.value.page = 1
}
async function handleGetData(isClearList = false) {
if (isClearList) {
dataList.value = []
queryParams.value.page = 1
}
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = ''
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = ''
try {
const res = await getPhotoListByGroupName({ ...queryParams.value })
hasNext.value = res.data.hasNext
loading.value = 'success'
if (res.data.items.length !== 0) {
const list = res.data.items.map(item => ({
...item,
spec: { ...item.spec, url: checkImageUrl(item.spec.url || item.spec.cover) },
}))
dataList.value = isLoadMore.value
? dataList.value.concat(list)
: list
}
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
lock.value = false
}, 500)
}
}
try {
const res = await getPhotoListByGroupName({ ...queryParams.value })
hasNext.value = res.data.hasNext
loading.value = 'success'
if (res.data.items.length !== 0) {
const list = res.data.items.map(item => ({
...item,
spec: { ...item.spec, url: checkImageUrl(item.spec.url || item.spec.cover) },
}))
dataList.value = isLoadMore.value
? dataList.value.concat(list)
: list
}
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
lock.value = false
}, 500)
}
}
function handleGetDataByCategory(index: number) {
const item = category.value.list[index]
if (!item)
return
queryParams.value.group = item.name || ''
queryParams.value.page = 1
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
dataList.value = []
handleGetData(true)
}
function handleGetDataByCategory(index : number, cate : IPhotoGroup) {
queryParams.value.group = cate.metadata.name || ''
queryParams.value.page = 1
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
dataList.value = []
category.value.activeIndex = index
handleGetData(true)
}
function handleOnCategoryChange(e: { index: number, name: number }) {
console.log('切换分类', e)
if (lock.value)
return
handleGetDataByCategory(e.index)
}
/* ---------------- 图片预览 ---------------- */
function handlePreview(data: IPhoto) {
const current = dataList.value.findIndex(x => x.metadata.name === data.metadata.name)
uni.previewImage({
current,
urls: dataList.value.map(x => x.spec.url),
indicator: 'number',
loop: true,
})
}
/* ---------------- 图片预览 ---------------- */
function handlePreview(data : IPhoto) {
const current = dataList.value.findIndex(x => x.metadata.name === data.metadata.name)
uni.previewImage({
current,
urls: dataList.value.map(x => x.spec.url),
indicator: 'number',
loop: true,
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
// 检查插件可用性
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
})
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
// 检查插件可用性
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
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 })
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()
return
}
dataList.value = []
isLoadMore.value = false
queryParams.value.page = 1
handleGetData(true)
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
dataList.value = []
isLoadMore.value = false
queryParams.value.page = 1
handleGetData(true)
})
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(false)
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
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(false)
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafa;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用图库功能哦请联系管理员"
@on-refresh="handleGetCategory"
/>
<template v-else>
<!-- 顶部切换 -->
<wd-tabs
v-if="category.list.length > 0"
v-model="category.activeIndex"
align="left"
sticky
:offset-top="0"
@change="handleOnCategoryChange"
>
<wd-tab v-for="cate in category.list" :key="cate.displayName" :title="cate.displayName" />
</wd-tabs>
<view class="bg-page min-h-screen w-screen flex flex-col pb-6">
<uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用图库功能哦请联系管理员" @on-refresh="handleGetCategory" />
<template v-else>
<wd-sticky>
<scroll-view :scroll-x="true" class="w-full whitespace-nowrap pt-3">
<view v-for="(cate,index) in category.list" :key="cate.spec.displayName"
class="uh-global-card-glass border ml-3 mb-1 px-4 py-1 uh-shadow-xs text-sm rounded-2xl inline-block"
:class="{
'bg-primary text-gray-900 font-bold': index === category.activeIndex,
}" @click="handleGetDataByCategory(index,cate)">
{{ cate.spec.displayName }}({{cate.status?.photoCount??0}})
</view>
</scroll-view>
</wd-sticky>
<!-- 骨架屏 -->
<view v-if="loading === 'loading'" class="loading-wrap box-border p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<!-- 骨架屏 -->
<view v-if="loading === 'loading'" class="loading-wrap box-border p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<!-- 错误态 -->
<view v-else-if="loading === 'error'" class="error-wrap h-[60vh] w-full flex flex-col items-center justify-center gap-6">
<wd-empty description="阿偶,获取数据失败了~" />
<wd-button size="small" plain type="primary" @click="handleGetCategory()">
刷新试试
</wd-button>
</view>
<!-- 错误态 -->
<view v-else-if="loading === 'error'"
class="error-wrap h-[60vh] w-full flex flex-col items-center justify-center gap-6">
<wd-empty description="阿偶,获取数据失败了~" />
<wd-button size="small" plain type="primary" @click="handleGetCategory()">
刷新试试
</wd-button>
</view>
<!-- 内容区域 -->
<view v-else class="content box-border w-full p-3">
<view v-if="dataList.length === 0" class="h-[70vh] w-full flex items-center justify-center content-empty">
<wd-empty description="博主还没有分享图片~" />
</view>
<block v-else>
<!-- 瀑布流(双列) -->
<view class="waterfall flex flex-wrap gap-1.5">
<view
v-for="(item, index) in dataList"
:key="index"
class="waterfall-item h-[250rpx] w-[calc(50%-6rpx)] overflow-hidden rounded-xl"
:class="{ 'is-even mt-3': index % 2 === 1 }"
>
<image
class="waterfall-img h-full w-full"
:src="item.spec.url"
mode="aspectFill"
lazy-load
@click="handlePreview(item)"
/>
</view>
</view>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.app-page {
display: flex;
flex-direction: column;
}
.error-wrap {
.error-wrap-inner {
/* 无额外样式 */
}
}
</style>
<!-- 内容区域 -->
<view v-else class="box-border w-full p-3">
<view v-if="dataList.length === 0"
class="h-[70vh] w-full flex items-center justify-center content-empty">
<wd-empty description="博主还没有分享图片~" />
</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 h-38 w-full overflow-hidden rounded-xl">
<image class="h-full w-full" :src="item.spec.url" mode="aspectFill" lazy-load
@click="handlePreview(item)" />
</view>
</view>
<view class="load-text w-full py-4 text-center text-xs text-gray-500">
{{ loadMoreText }}
</view>
</block>
</view>
</template>
</view>
</template>
+193 -384
View File
@@ -1,419 +1,228 @@
<script lang="ts" setup>
/**
* 首页(源自旧项目 pages/tabbar/home/home.vue,新建复刻)
* 功能:顶部栏 + 轮播 Banner + 快捷导航 + 精选分类 + 最新文章列表(分页) + 通知弹窗
*/
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getCategoryList, getPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
import type { ICategory, IPost } from '@/api/types/halo'
import type { IBannerItem } from '@/components/uh-swiper/uh-swiper.vue'
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
import type { IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '首页',
enablePullDownRefresh: true,
navigationStyle: 'custom',
backgroundColor: '#F8F8F8',
},
})
definePage({
style: {
navigationBarTitleText: '首页',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const haloConfigs = computed(() => appConfigStore.configs)
const haloConfigs = computed(() => appConfigStore.configs)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const articleList = ref<IPost[]>([])
const categoryList = ref<ICategory[]>([])
const result = ref<{ hasNext: boolean }>({ hasNext: false })
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const articleList = ref<IPost[]>([])
const queryParams = ref({
size: 5,
page: 1,
sort: ['spec.pinned,desc', 'spec.publishTime,desc'],
})
const result = ref<{ hasNext : boolean }>({ hasNext: false })
/* ---------------- 计算属性 ---------------- */
const appInfo = computed(() => {
const appInfoData = haloConfigs.value.appConfig?.appInfo as { name?: string, logo?: string } | undefined
return {
name: appInfoData?.name || 'uni-halo',
logo: checkImageUrl(appInfoData?.logo),
}
})
const queryParams = ref({
size: 5,
page: 1,
sort: ['spec.pinned,desc', 'spec.publishTime,desc'],
})
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
/* ---------------- 计算属性 ---------------- */
const appInfo = computed(() => {
const appInfoData = haloConfigs.value.appConfig?.appInfo as { name ?: string, logo ?: string } | undefined
return {
name: appInfoData?.name || 'uni-halo',
logo: checkImageUrl(appInfoData?.logo),
}
})
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname ?: string, avatar ?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const calcIsShowQuickNavigationEnabled = computed(() => haloConfigs.value.pageConfig?.homeConfig?.useQuickNavigation)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcIsShowCategory = computed(() => {
if (calcAuditModeEnabled.value)
return false
return !!haloConfigs.value.pageConfig?.homeConfig?.useCategory
})
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
const globalAppSettings = computed(() => settingStore.settings)
const bannerConfig = computed(() => haloConfigs.value.pageConfig?.homeConfig?.bannerConfig)
const globalAppSettings = computed(() => settingStore.settings)
/* ---------------- 数据加载 ---------------- */
async function handleQuery() {
handleGetArticleList()
}
/** 快捷导航列表(由配置控制显隐) */
const navList = computed(() => {
const loveEnabled = !!(haloConfigs.value.loveConfig as { loveEnabled?: boolean })?.loveEnabled
const socialEnabled = !!(haloConfigs.value.authorConfig?.social as { enabled?: boolean } | undefined)?.enabled
return [
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
bgColor: 'rgba(3, 169, 244, 0.95)',
icon: 'news',
path: '/pages-blog/archives/archives',
show: true,
},
{
key: 'vote',
title: '投票中心',
bgColor: 'rgba(0, 188, 212, 0.95)',
icon: 'box',
path: '/pages-blog/votes/votes',
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
},
{
key: 'disclaimers',
title: '友情链接',
bgColor: 'rgba(0, 150, 136, 0.95)',
icon: 'link',
path: '/pages-blog/friend-links/friend-links',
show: calcLinksPluginEnabled.value,
},
{
key: 'love',
title: '恋爱日记',
bgColor: 'rgba(255, 76, 103, 0.95)',
icon: 'heart',
path: '/pages-blog/love/love',
show: loveEnabled,
},
{
key: 'contact-blogger',
title: '联系博主',
bgColor: 'rgba(255, 152, 0, 0.95)',
icon: 'message',
path: '/pages-blog/contact/contact',
show: socialEnabled,
},
]
})
/** 文章列表 */
async function handleGetArticleList() {
if (calcAuditModeEnabled.value) {
// 审核模式:真实文章按 audit-data posts 过滤(数组顺序即展示顺序)
const auditPostNames = appConfigStore.auditData.spec?.posts || []
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)=>{
item.owner.avatar = checkAvatarUrl(item.owner.avatar);
return item;
})
loading.value = 'success'
loadMoreText.value = t('common.noMore')
}
catch (err) {
console.error('获取审核文章失败', err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
return
}
/* ---------------- 数据加载 ---------------- */
async function handleQuery() {
// 轮播图数据由 uh-swiper 组件内部请求公开 banners 接口,页面不再组装
await Promise.all([handleGetArticleList(), handleGetCategoryList()])
}
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
/** 精选分类 */
async function handleGetCategoryList() {
if (calcAuditModeEnabled.value || !calcIsShowCategory.value) {
loading.value = 'success'
return
}
try {
const res = await getCategoryList({ fieldSelector: ['spec.hideFromList=false'], size: 10 })
categoryList.value = res.data.items
.map(item => ({ ...item, postCount: item.postCount ?? 0 }))
.sort((a, b) => (b.postCount || 0) - (a.postCount || 0))
loading.value = 'success'
}
catch (err) {
console.error('获取分类失败', err)
loading.value = 'error'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
try {
const res = await getPostList({ ...toRaw(queryParams.value) })
result.value.hasNext = res.data.hasNext
articleList.value = (isLoadMore.value
? articleList.value.concat(res.data.items)
: res.data.items).map((item)=>{
item.owner.avatar = checkAvatarUrl(item.owner.avatar);
return item;
})
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
catch (err) {
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
console.error('获取文章失败', err)
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
/** 文章列表 */
async function handleGetArticleList() {
if (calcAuditModeEnabled.value) {
// 审核模式:真实文章按 audit-data posts 过滤(数组顺序即展示顺序)
const auditPostNames = appConfigStore.auditData.spec?.posts || []
try {
const res = await getPostList({ page: 1, size: 99999, sort: ['spec.publishTime,desc'] })
const filtered = res.data.items.filter(item => auditPostNames.includes(item.metadata.name))
const orderMap = new Map(auditPostNames.map((name, index) => [name, index]))
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
articleList.value = filtered
loading.value = 'success'
loadMoreText.value = t('common.noMore')
}
catch (err) {
console.error('获取审核文章失败', err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
return
}
/* ---------------- 跳转 ---------------- */
function handleToArticleDetail(article : IPost) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
function handleToSearch() {
uni.navigateTo({ url: '/pages-blog/search/search' })
}
try {
const res = await getPostList({ ...toRaw(queryParams.value) })
result.value.hasNext = res.data.hasNext
articleList.value = isLoadMore.value
? articleList.value.concat(res.data.items)
: res.data.items
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
catch (err) {
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
console.error('获取文章失败', err)
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
function handleOnLogoToPage() {
uni.switchTab({ url: '/pages/tabbar/about/about' })
}
/* ---------------- 跳转 ---------------- */
function handleToArticleDetail(article: IPost) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function handleToCategoryPage() {
uni.switchTab({ url: '/pages/tabbar/category/category' })
}
function handleToCategoryBy(category: ICategory) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
function handleToSearch() {
uni.navigateTo({ url: '/pages-blog/search/search' })
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: t('page.home.title') })
})
function handleOnLogoToPage() {
uni.switchTab({ url: '/pages/tabbar/about/about' })
}
watch(haloConfigs, () => {
// 配置就绪后重新拉取(导航显隐依赖配置)
}, { deep: true })
function handleClickNav(item: { path: string }) {
uni.navigateTo({ url: item.path })
}
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 1
handleQuery()
})
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
onReachBottom(() => {
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (result.value.hasNext) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetArticleList()
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
function handleOnBannerClick(item: IBannerItem) {
// 审核模式下照常展示 Banner,点击分发不拦截(详情页自行处理审核限制)
if (item.type === 'custom') {
// 自定义条目:跳转 Banner 详情页,页面内调公开详情接口展示 content/外链
if (item.name) {
uni.navigateTo({
url: `/pages-blog/banner-detail/banner-detail?name=${item.name}`,
animationType: 'slide-in-right',
})
}
return
}
// 文章来源条目:用 postId 跳文章详情
const postId = item.postId || String(item.id || '')
if (!postId)
return
handleToArticleDetail({ metadata: { name: postId } } as IPost)
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: t('page.home.title') })
})
watch(haloConfigs, () => {
// 配置就绪后重新拉取(导航显隐依赖配置)
}, { deep: true })
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 1
handleQuery()
})
onReachBottom(() => {
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (result.value.hasNext) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetArticleList()
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
// 首次加载
handleQuery()
// 首次加载
handleQuery()
</script>
<template>
<view class="min-h-screen w-screen flex flex-col">
<!-- 顶部栏 -->
<view class="header flex items-center gap-4 px-3 py-1.5">
<image class="logo h-[60rpx] w-[60rpx] rounded-3xl" :src="appInfo.logo" mode="scaleToFill" @click="handleOnLogoToPage" />
<view class="search-input h-[64rpx] flex flex-1 items-center rounded-3xl bg-[#f5f5f5] px-3" @click="handleToSearch">
<view class="search-icon flex items-center">
<wd-icon name="search" size="16px" color="#999" />
</view>
<text class="search-text text-grey ml-3 text-[26rpx] text-[#999]">搜索内容...</text>
</view>
<!-- #ifdef APP-PLUS || H5 -->
<view class="app-name max-w-[140rpx] overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#666]">
{{ appInfo.name }}
</view>
<!-- #endif -->
</view>
<view class="bg-page min-h-screen w-screen flex flex-col">
<!-- 骨架屏 -->
<view v-if="loading !== 'success' && articleList.length === 0" class="loading-wrap px-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<!-- 骨架屏 -->
<view v-if="loading !== 'success' && articleList.length === 0" class="loading-wrap px-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<block v-else>
<!-- 轮播-->
<uh-home-banner />
<block v-else>
<!-- 轮播 Banner(数据由 uh-swiper 组件内部请求公开 banners 接口) -->
<view v-if="bannerConfig?.enabled" class="mb-4 bg-white">
<view class="banner mx-3 mt-3 overflow-hidden rounded-xl">
<uh-swiper
:height="bannerConfig.height"
:dot-position="bannerConfig.dotPosition"
:autoplay="true"
:use-dot="bannerConfig.showIndicator"
:use-title="bannerConfig.showTitle"
@on-click="handleOnBannerClick"
/>
</view>
</view>
<!-- 快捷导航 -->
<uh-home-quick-nav />
<!-- 快捷导航 -->
<view v-if="navList.filter(x => x.show).length" class="nav-box overflow-hidden rounded-xl bg-white p-3 px-4">
<view class="page-item-title font-bold">
快捷导航
</view>
<view class="nav-list grid grid-cols-5 mt-6 gap-6">
<template v-for="item in navList.filter(x => x.show)" :key="item.key">
<view class="nav-item flex flex-col items-center gap-3" @click="handleClickNav(item)">
<view class="nav-item-icon h-[88rpx] w-[88rpx] flex items-center justify-center rounded-3xl" :style="{ backgroundColor: item.bgColor }">
<wd-icon :name="item.icon" size="24px" color="#fff" />
</view>
<view class="nav-item-text text-[24rpx] text-[#303133]">
{{ item.title }}
</view>
</view>
</template>
</view>
</view>
<!-- 精选分类 -->
<uh-home-category />
<!-- 精选分类 -->
<block v-if="calcIsShowCategory">
<view class="mb-6 mt-6 flex items-center justify-between px-3">
<view class="page-item-title font-bold">
精选分类
</view>
<view class="show-more flex items-center justify-center rounded-xl bg-white" @click="handleToCategoryPage">
<wd-icon name="arrow-right" size="12px" color="#909399" />
</view>
</view>
<scroll-view class="category mx-6 h-[200rpx] whitespace-nowrap" :scroll-x="true">
<view v-if="categoryList.length === 0" class="cate-empty text-grey h-[180rpx] w-full flex items-center justify-center">
还没有任何分类~
</view>
<view
v-for="category in categoryList"
v-else
:key="category.metadata.name"
class="category-item mr-4 inline-block"
@click="handleToCategoryBy(category)"
>
<uh-category-mini-card :category="category" />
</view>
</scroll-view>
</block>
<!-- 最新文章 -->
<view class="mb-6 mt-6 flex items-center justify-between px-3">
<view class="page-item-title font-bold">
最新列表
</view>
<view class="show-more flex items-center justify-center rounded-xl bg-white" @click="handleToSearch">
<wd-icon name="arrow-right" size="12px" color="#909399" />
</view>
</view>
<view v-if="articleList.length === 0" class="article-empty py-10">
<wd-empty description="博主还没有发表任何内容~" />
</view>
<block v-else>
<view :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-[24rpx] text-[#999]">
{{ 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>
</template>
<!-- 最新文章 -->
<uh-section-title class="mb-4 px-3 box-border">
最新内容
<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>
<block v-else>
<view class="p-3 pt-0 flex flex-col gap-y-3" :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>
</template>
+149 -18
View File
@@ -20,6 +20,8 @@ definePage({
style: {
navigationBarTitleText: '瞬间',
enablePullDownRefresh: true,
// 玻璃拟态试验:下拉/回弹露出的窗口底色对齐壁纸底部色调
backgroundColor: '#f4efff',
},
})
@@ -36,7 +38,11 @@ const bloggerInfo = computed(() => {
}
})
const startConfig = computed(() => haloConfigs.value.appConfig?.startConfig as { title?: string } | undefined)
/** 站点名称(原 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'
@@ -46,7 +52,14 @@ const uniHaloPluginAvailable = ref(true)
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 1 })
const hasNext = ref(false)
const dataList = ref<(IMoment & { images?: { type?: string, url: string }[], videos?: { id?: string, url: string }[], audios?: { type?: string, url: string }[], spec: { newHtml?: string } })[]>([])
/** 列表卡片: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>>({})
@@ -58,18 +71,20 @@ function removeTagLinksCompletely(htmlString: string): string {
return htmlString.replace(regex, '')
}
/** 瞬间项映射(medium 拆分为 images/videos/audios + 内容 tag 清理) */
function mapMomentItem(item: IMoment) {
const medium = (item.spec as unknown as { medium?: { type?: string, url: string }[] }).medium || []
/** 瞬间项映射(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,
owner: {
displayName: bloggerInfo.value.nickname,
avatar: bloggerInfo.value.avatar,
},
newHtml: removeTagLinksCompletely((item.spec as unknown as { content?: { html?: string } }).content?.html || ''),
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() })),
@@ -251,7 +266,15 @@ onReachBottom(() => {
</script>
<template>
<view class="box-border min-h-screen w-screen flex flex-col py-6">
<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"
@@ -263,19 +286,19 @@ onReachBottom(() => {
<wd-skeleton :row="3" :animated="true" />
</view>
<view v-else class="flex flex-col gap-y-2 p-4">
<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>
<block v-else>
<!-- 瞬间卡片 -->
<view v-for="moment in dataList" :key="moment.metadata.name" class="flex flex-col overflow-hidden rounded-xl bg-white shadow-sm">
<!-- 瞬间卡片(玻璃) -->
<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.spec.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<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.spec.owner?.displayName || bloggerInfo.nickname }}
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1 text-[24rpx] text-[#666]">
{{ formatMomentTime(moment.spec.releaseTime) }}
@@ -320,7 +343,7 @@ onReachBottom(() => {
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${startConfig?.title || bloggerInfo.nickname}的声音`"
:name="`来自${siteName}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
@@ -349,9 +372,21 @@ onReachBottom(() => {
{{ tag }}
</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 class="to-top-btn fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<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]">
@@ -362,3 +397,99 @@ onReachBottom(() => {
</template>
</view>
</template>
<style scoped lang="scss">
/* 苹果风玻璃拟态试验(测试点:瞬间页)
* 原理:页面固定一层多彩渐变"壁纸",卡片用半透明白 + backdrop-filter,
* 壁纸的颜色透过玻璃才看得见(纯白背景看不出毛玻璃)。
*/
.moments-page {
/* 兜底底色(壁纸固定层异常时页面不至于纯白) */
background-color: #eef1fd;
}
.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%
);
}
/* 柔光光斑:以软径向渐变直接呈现"虚化"质感(免 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>