mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-12 16:40:40 +08:00
feat: 重构文章卡片与列表页,新增多布局与筛选功能
1. 新增文章卡片grid紧凑布局,支持隐藏分类标签与单行摘要 2. 重构文章列表页,添加分类筛选、排序功能与两列卡片布局 3. 首页新增推荐模式切换,支持默认/置顶/最新/最旧排序 4. 统一代码缩进与格式规范,完善类型定义与注释
This commit is contained in:
@@ -1,105 +1,125 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { checkThumbnailUrl } from '@/utils/url'
|
import { checkThumbnailUrl } from '@/utils/url'
|
||||||
import { useSettingStore } from '@/store/setting'
|
import { useSettingStore } from '@/store/setting'
|
||||||
import { formatTime } from '@/utils/formatTime'
|
import { formatTime } from '@/utils/formatTime'
|
||||||
import type { IPost,ICategory } from '@/api/types/halo'
|
import type { ICategory, IPost } from '@/api/types/halo'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
from ?: string
|
from?: string
|
||||||
auditMode?: boolean
|
auditMode?: boolean
|
||||||
article : IPost
|
article: IPost
|
||||||
}>(), {
|
/** 卡片形态:list=常规列表卡(默认),grid=两列紧凑卡(隐藏分类/标签,摘要单行) */
|
||||||
auditMode: false,
|
variant?: 'list' | 'grid'
|
||||||
from: '',
|
}>(), {
|
||||||
})
|
auditMode: false,
|
||||||
|
from: '',
|
||||||
|
variant: 'list',
|
||||||
|
})
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
/** 卡片布局 class(由全局设置 layout 决定) */
|
/** 卡片布局 class(由全局设置 layout 决定) */
|
||||||
const cardType = computed(() => {
|
const cardType = computed(() => {
|
||||||
const layout = settingStore.settings.layout
|
const layout = settingStore.settings.layout
|
||||||
// 首页双列时强制上图下文布局,除非显式指定其他
|
// 首页双列时强制上图下文布局,除非显式指定其他
|
||||||
if (props.from === 'home' && layout.home === 'h_row_col2') {
|
if (props.from === 'home' && layout.home === 'h_row_col2') {
|
||||||
if (!['tb_image_text', 'tb_text_image', 'only_text'].includes(layout.cardType)) {
|
if (!['tb_image_text', 'tb_text_image', 'only_text'].includes(layout.cardType)) {
|
||||||
return [props.from, layout.home, 'tb_image_text']
|
return [props.from, layout.home, 'tb_image_text']
|
||||||
}
|
}
|
||||||
return [props.from, layout.home, layout.cardType]
|
return [props.from, layout.home, layout.cardType]
|
||||||
}
|
}
|
||||||
return [layout.home, layout.cardType]
|
return [layout.home, layout.cardType]
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 发布时间格式化 yyyy-MM-dd */
|
/** grid 紧凑模式(两列:隐藏分类/标签,摘要单行,底部精简) */
|
||||||
const publishTimeText = computed(() => {
|
const isGrid = computed(() => props.variant === 'grid')
|
||||||
const time = props.article.spec.publishTime
|
|
||||||
return time ? formatTime({ d: time, f: 'yyyy-MM-dd' }) : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 阅读数(兼容 status.stats.visits 与旧版顶层 stats.visit) */
|
/** 发布时间格式化 yyyy-MM-dd */
|
||||||
const visitCount = computed(() => {
|
const publishTimeText = computed(() => {
|
||||||
return props.article.status?.stats?.visits ?? props.article.stats?.visit ?? 0
|
const time = props.article.spec.publishTime
|
||||||
})
|
return time ? formatTime({ d: time, f: 'yyyy-MM-dd' }) : ''
|
||||||
|
})
|
||||||
|
|
||||||
function handleToArticleDetail() {
|
/** 阅读数(兼容 status.stats.visits 与旧版顶层 stats.visit) */
|
||||||
uni.navigateTo({
|
const visitCount = computed(() => {
|
||||||
url: `/pages-blog/article-detail/article-detail?name=${props.article.metadata.name}`,
|
return props.article.status?.stats?.visits ?? props.article.stats?.visit ?? 0
|
||||||
animationType: 'slide-in-right',
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToCategory(category : ICategory) {
|
function handleToArticleDetail() {
|
||||||
if (props.auditMode) {
|
uni.navigateTo({
|
||||||
return
|
url: `/pages-blog/article-detail/article-detail?name=${props.article.metadata.name}`,
|
||||||
}
|
animationType: 'slide-in-right',
|
||||||
uni.navigateTo({
|
})
|
||||||
url: `/pages-blog/category-articles/category-articles?name=${category.metadata.name}&title=${category.spec.displayName}`,
|
}
|
||||||
})
|
|
||||||
}
|
function handleToCategory(category: ICategory) {
|
||||||
|
if (props.auditMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-blog/category-articles/category-articles?name=${category.metadata.name}&title=${category.spec.displayName}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="uh-global-card-glass uh-shadow-xs overflow-hidden relative rounded-xl p-3"
|
<view
|
||||||
@click.stop="handleToArticleDetail()">
|
class="uh-global-card-glass uh-shadow-xs relative overflow-hidden rounded-xl p-3"
|
||||||
<text v-if="article.spec.pinned"
|
@click.stop="handleToArticleDetail()"
|
||||||
class="absolute right-6 top-6 z-1 bg-secondary text-gray-60 text-xs px-2 py-1 rounded-lg"> 置顶 </text>
|
>
|
||||||
<image class="w-full h-36 rounded-lg" :src="checkThumbnailUrl(article.spec.cover)" mode="aspectFill"
|
<text
|
||||||
lazy-load />
|
v-if="article.spec.pinned"
|
||||||
<view class="flex flex-col w-full gap-y-2 text-sm">
|
class="text-gray-60 absolute right-6 top-6 z-1 rounded-lg bg-secondary px-2 py-1 text-xs"
|
||||||
<view class="mt-2 font-bold truncate">
|
>
|
||||||
{{ article.spec.title }}
|
置顶
|
||||||
</view>
|
</text>
|
||||||
<view class="content line-clamp-2 text-gray-600">
|
<image
|
||||||
{{ article.status?.excerpt }}
|
:class="isGrid ? 'w-full h-24 rounded-lg' : 'w-full h-36 rounded-lg'"
|
||||||
</view>
|
:src="checkThumbnailUrl(article.spec.cover)" mode="aspectFill" lazy-load
|
||||||
<view class="my-1 box-border flex flex-wrap gap-2">
|
/>
|
||||||
<template v-if="article.categories && article.categories.length !== 0">
|
<view class="w-full flex flex-col gap-y-2 text-sm">
|
||||||
<text v-for="cate in article.categories" :key="cate.metadata.name"
|
<view class="mt-2 truncate font-bold">
|
||||||
class="py-1 px-2 text-xs rounded-xl bg-secondary" @click.stop="handleToCategory(cate)">
|
{{ article.spec.title }}
|
||||||
{{ cate.spec.displayName }}
|
</view>
|
||||||
</text>
|
<view :class="isGrid ? 'content line-clamp-1 text-gray-600' : 'content line-clamp-2 text-gray-600'">
|
||||||
</template>
|
{{ article.status?.excerpt }}
|
||||||
<template v-if="article.tags && article.tags.length !== 0">
|
</view>
|
||||||
<text v-for="tag in article.tags" :key="tag.metadata.name"
|
<view v-if="!isGrid" class="my-1 box-border flex flex-wrap gap-2">
|
||||||
class="py-1 px-2 text-xs rounded-xl bg-secondary">
|
<template v-if="article.categories && article.categories.length !== 0">
|
||||||
# {{ tag.spec.displayName }}
|
<text
|
||||||
</text>
|
v-for="cate in article.categories" :key="cate.metadata.name"
|
||||||
</template>
|
class="rounded-xl bg-secondary px-2 py-1 text-xs" @click.stop="handleToCategory(cate)"
|
||||||
</view>
|
>
|
||||||
<view class="flex items-center justify-between text-xs text-gray-500">
|
{{ cate.spec.displayName }}
|
||||||
<view class="flex items-center gap-x-1">
|
</text>
|
||||||
<image :src="article.owner.avatar" class="uh-global-card-glass rounded-full w-5 h-5"
|
</template>
|
||||||
mode="aspectFill"></image>
|
<template v-if="article.tags && article.tags.length !== 0">
|
||||||
<text>{{article.owner.displayName}}</text>
|
<text
|
||||||
</view>
|
v-for="tag in article.tags" :key="tag.metadata.name"
|
||||||
<view class="flex items-center gap-x-2">
|
class="rounded-xl bg-secondary px-2 py-1 text-xs"
|
||||||
{{ publishTimeText }}
|
>
|
||||||
</view>
|
# {{ tag.spec.displayName }}
|
||||||
<view class="visits">
|
</text>
|
||||||
浏览
|
</template>
|
||||||
<text class="number">{{ visitCount }}</text>
|
</view>
|
||||||
次
|
<view class="mt-1 flex items-center justify-between text-xs text-gray-500">
|
||||||
</view>
|
<view v-if="!isGrid" class="flex items-center gap-x-1">
|
||||||
</view>
|
<image
|
||||||
</view>
|
:src="article.owner.avatar" class="uh-global-card-glass h-5 w-5 rounded-full"
|
||||||
</view>
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<text>{{ article.owner.displayName }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="flex items-center gap-x-2">
|
||||||
|
{{ publishTimeText }}
|
||||||
|
</view>
|
||||||
|
<view class="visits">
|
||||||
|
浏览
|
||||||
|
<text class="number">{{ visitCount }}</text>
|
||||||
|
次
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,33 +1,297 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
/**
|
||||||
|
* 文章列表页(替代 unibest 模板占位页)
|
||||||
|
* 标准布局:uh-navbar + useDataLoadingStatus 四态 + uh-data-loading + 分页加载 + 回顶
|
||||||
|
* 2026-09-08:新增分类筛选 + 排序(参考投票中心胶囊弹层),列表改 grid 两列紧凑卡片
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { getCategoryList, getPostList } from '@/api/halo'
|
||||||
|
import { useAppConfigStore } from '@/store/appConfig'
|
||||||
|
import { checkAvatarUrl } from '@/utils/url'
|
||||||
|
import { t } from '@/locale'
|
||||||
|
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||||
|
import type { ICategory, IPost } from '@/api/types/halo'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '文章列表',
|
navigationBarTitleText: '文章列表',
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
},
|
enablePullDownRefresh: true,
|
||||||
})
|
},
|
||||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
const appConfigStore = useAppConfigStore()
|
||||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||||
}, 3000)
|
|
||||||
|
/* ---------------- 状态 ---------------- */
|
||||||
|
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||||
|
const articleList = ref<IPost[]>([])
|
||||||
|
const queryParams = ref({ size: 10, page: 1 })
|
||||||
|
const hasNext = ref(false)
|
||||||
|
const isLoadMore = ref(false)
|
||||||
|
const loadMoreText = ref(t('common.loading'))
|
||||||
|
|
||||||
|
/* ---------------- 筛选与排序(参考投票中心) ---------------- */
|
||||||
|
interface IFilterOption {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFilterItem {
|
||||||
|
key: 'category' | 'sort'
|
||||||
|
label: string
|
||||||
|
options: IFilterOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类列表(筛选选项数据源) */
|
||||||
|
const categoryList = ref<ICategory[]>([])
|
||||||
|
|
||||||
|
/** 排序参数映射 */
|
||||||
|
const sortMap: Record<string, string[]> = {
|
||||||
|
default: ['spec.pinned,desc', 'spec.publishTime,desc'],
|
||||||
|
latest: ['spec.publishTime,desc'],
|
||||||
|
oldest: ['spec.publishTime,asc'],
|
||||||
|
pinned: ['spec.pinned,desc'],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 筛选维度:分类筛选 + 排序 */
|
||||||
|
const filterConfig = computed<IFilterItem[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'category',
|
||||||
|
label: '分类',
|
||||||
|
options: [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
...categoryList.value.map(c => ({ label: c.spec.displayName, value: c.metadata.name })),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sort',
|
||||||
|
label: '排序',
|
||||||
|
options: [
|
||||||
|
{ label: '默认排序', value: 'default' },
|
||||||
|
{ label: '最新', value: 'latest' },
|
||||||
|
{ label: '最旧', value: 'oldest' },
|
||||||
|
{ label: '置顶', value: 'pinned' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 各维度当前选中值(空串 = 全部) */
|
||||||
|
const filterValues = ref<Record<string, string>>({ category: '', sort: 'default' })
|
||||||
|
|
||||||
|
/** 当前选中中文标签(用于筛选栏展示) */
|
||||||
|
const filterLabels = computed(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const f of filterConfig.value) {
|
||||||
|
const cur = filterValues.value[f.key]
|
||||||
|
map[f.key] = f.options.find(o => o.value === cur)?.label || f.options[0].label
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 筛选弹层 */
|
||||||
|
const filterPopup = ref<{ show: boolean, item: IFilterItem | null }>({ show: false, item: null })
|
||||||
|
|
||||||
|
function handleOpenFilter(item: IFilterItem) {
|
||||||
|
filterPopup.value = { show: true, item }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择筛选/排序:重置分页并重新查询 */
|
||||||
|
function handleSelectFilter(option: IFilterOption) {
|
||||||
|
const item = filterPopup.value.item
|
||||||
|
if (!item)
|
||||||
|
return
|
||||||
|
filterValues.value[item.key] = option.value
|
||||||
|
filterPopup.value.show = false
|
||||||
|
isLoadMore.value = false
|
||||||
|
articleList.value = []
|
||||||
|
queryParams.value.page = 1
|
||||||
|
handleGetArticleList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取分类列表(用于筛选选项) */
|
||||||
|
async function handleGetCategoryList() {
|
||||||
|
try {
|
||||||
|
const res = await getCategoryList({ page: 1, size: 0, tree: true })
|
||||||
|
categoryList.value = res.data.items || []
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('获取分类列表失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 数据加载 ---------------- */
|
||||||
|
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
|
||||||
|
})
|
||||||
|
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||||
|
loadMoreText.value = t('common.noMore')
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('获取审核文章失败', err)
|
||||||
|
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||||
|
loadMoreText.value = t('common.loadFailed')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadMore.value) {
|
||||||
|
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||||
|
}
|
||||||
|
loadMoreText.value = t('common.loading')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 应用分类筛选与排序参数
|
||||||
|
const params = {
|
||||||
|
...queryParams.value,
|
||||||
|
category: filterValues.value.category || undefined,
|
||||||
|
sort: sortMap[filterValues.value.sort] || sortMap.default,
|
||||||
|
}
|
||||||
|
const res = await getPostList(params)
|
||||||
|
hasNext.value = 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
|
||||||
|
})
|
||||||
|
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||||
|
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||||
|
loadMoreText.value = t('common.loadFailed')
|
||||||
|
console.error('获取文章失败', err)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToTopPage(duration = 500) {
|
||||||
|
uni.pageScrollTo({
|
||||||
|
scrollTop: 0,
|
||||||
|
duration,
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('回顶失败', err)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 生命周期 ---------------- */
|
||||||
|
onLoad(() => {
|
||||||
|
handleGetCategoryList()
|
||||||
|
handleGetArticleList()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
if (calcAuditModeEnabled.value) {
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoadMore.value = false
|
||||||
|
queryParams.value.page = 1
|
||||||
|
articleList.value = []
|
||||||
|
handleGetArticleList()
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (calcAuditModeEnabled.value) {
|
||||||
|
uni.showToast({ icon: 'none', title: '没有更多数据了' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasNext.value) {
|
||||||
|
queryParams.value.page += 1
|
||||||
|
isLoadMore.value = true
|
||||||
|
handleGetArticleList()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
uni.showToast({ icon: 'none', title: '没有更多数据了' })
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="w-full min-h-screen bg-page">
|
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
|
||||||
<uh-navbar default-title="文章列表" :need-placeholder="true" title-color="text-gray-900"></uh-navbar>
|
<!-- 自定义导航 -->
|
||||||
|
<uh-navbar default-title="文章列表" title-color="text-gray-900" />
|
||||||
|
|
||||||
<!-- 内容区:由于 uh-navbar 内置有占位,所以我们的页面的主要内容应该从这里开始,比如这里就可以设置内边距或者其他样式,最外层的 <view class="w-full min-h-screen bg-page"> 仅作为容器-->
|
<!-- 筛选栏:分类 + 排序 -->
|
||||||
<view class="box-border px-3">
|
<view class="box-border flex items-center justify-between gap-x-2 px-3 pt-2">
|
||||||
<!-- 加载状态 -->
|
<view
|
||||||
<uh-data-loading v-if="loadingStatus!==DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
v-for="f in filterConfig" :key="f.key"
|
||||||
min-height="80vh"></uh-data-loading>
|
class="uh-global-card-glass box-border flex flex-1 items-center justify-center gap-1 border rounded-full px-2 py-1 text-gray-500"
|
||||||
|
:class="[filterValues[f.key] !== f.options[0].value ? 'bg-secondary text-gray-900 font-bold' : 'bg-white/80 text-gray-600']"
|
||||||
|
@click="handleOpenFilter(f)"
|
||||||
|
>
|
||||||
|
<text class="truncate text-xs">{{ filterLabels[f.key] }}</text>
|
||||||
|
<wd-icon name="arrow-down" size="10px" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 实际内容 -->
|
<!-- 加载/错误/空占位(状态机) -->
|
||||||
<view v-else>
|
<uh-data-loading
|
||||||
请求成功啦
|
v-if="loadingStatus !== DataLoadingStatusEnum.Success"
|
||||||
</view>
|
:loading-status="loadingStatus"
|
||||||
</view>
|
empty-text="博主还没有发布文章呢~"
|
||||||
</view>
|
min-height="60vh"
|
||||||
|
@refresh="handleGetArticleList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 文章列表(grid 两列) -->
|
||||||
|
<view v-else class="box-border flex flex-col gap-4 p-3">
|
||||||
|
<view class="grid grid-cols-2 gap-3">
|
||||||
|
<uh-article-card
|
||||||
|
v-for="(article, index) in articleList"
|
||||||
|
:key="article.metadata.name || index"
|
||||||
|
from="articles"
|
||||||
|
variant="grid"
|
||||||
|
:article="article"
|
||||||
|
:audit-mode="calcAuditModeEnabled"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="box-border py-5 text-center text-xs text-gray-400">
|
||||||
|
{{ loadMoreText }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 回顶 -->
|
||||||
|
<view
|
||||||
|
class="to-top-btn fixed bottom-[160rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm"
|
||||||
|
@click="handleToTopPage()"
|
||||||
|
>
|
||||||
|
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 筛选弹层 -->
|
||||||
|
<uh-glass-popup v-model="filterPopup.show" :z-index="99" position="bottom" custom-class="rounded-2xl">
|
||||||
|
<view v-if="filterPopup.item" class="box-border p-4">
|
||||||
|
<view class="text-md mb-4 text-center text-gray-900 font-bold">
|
||||||
|
{{ filterPopup.item.label }}
|
||||||
|
</view>
|
||||||
|
<view class="flex flex-col gap-2">
|
||||||
|
<view
|
||||||
|
v-for="opt in filterPopup.item.options" :key="opt.label"
|
||||||
|
class="uh-global-card-glass box-border border rounded-xl px-5 py-2 text-center text-sm"
|
||||||
|
:class="filterValues[filterPopup.item.key] === opt.value ? 'bg-primary text-gray-900 font-bold' : 'text-gray-700'"
|
||||||
|
@click="handleSelectFilter(opt)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</uh-glass-popup>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
+239
-189
@@ -1,224 +1,274 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
|
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||||
import { getPostList } from '@/api/halo'
|
import { getPostList } from '@/api/halo'
|
||||||
import { useAppConfigStore } from '@/store/appConfig'
|
import { useAppConfigStore } from '@/store/appConfig'
|
||||||
import { useSettingStore } from '@/store/setting'
|
import { useSettingStore } from '@/store/setting'
|
||||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||||
import { t } from '@/locale'
|
import { t } from '@/locale'
|
||||||
import { useMaintenanceIntercept } from '@/hooks/useMaintenanceIntercept'
|
import { useMaintenanceIntercept } from '@/hooks/useMaintenanceIntercept'
|
||||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||||
import type { IPost } from '@/api/types/halo'
|
import type { IPost } from '@/api/types/halo'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '首页',
|
navigationBarTitleText: '首页',
|
||||||
enablePullDownRefresh: true,
|
enablePullDownRefresh: true,
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const appConfigStore = useAppConfigStore()
|
const appConfigStore = useAppConfigStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
/** 维护拦截(插件可用性 + 维护模式,任一命中跳维护页;与入口页共用 hooks) */
|
/** 维护拦截(插件可用性 + 维护模式,任一命中跳维护页;与入口页共用 hooks) */
|
||||||
const { interceptOrContinue } = useMaintenanceIntercept()
|
const { interceptOrContinue } = useMaintenanceIntercept()
|
||||||
/** 是否已被拦截(配置已带维护键时同步置位,避免首载闪跳) */
|
/** 是否已被拦截(配置已带维护键时同步置位,避免首载闪跳) */
|
||||||
const intercepted = ref(!!appConfigStore.configs.maintenance)
|
const intercepted = ref(!!appConfigStore.configs.maintenance)
|
||||||
|
|
||||||
const haloConfigs = computed(() => appConfigStore.configs)
|
const haloConfigs = computed(() => appConfigStore.configs)
|
||||||
|
|
||||||
/* ---------------- 状态 ---------------- */
|
/* ---------------- 状态 ---------------- */
|
||||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||||
const isLoadMore = ref(false)
|
const isLoadMore = ref(false)
|
||||||
const loadMoreText = ref(t('common.loading'))
|
const loadMoreText = ref(t('common.loading'))
|
||||||
const articleList = ref<IPost[]>([])
|
const articleList = ref<IPost[]>([])
|
||||||
|
|
||||||
const result = ref<{ hasNext : boolean }>({ hasNext: false })
|
const result = ref<{ hasNext: boolean }>({ hasNext: false })
|
||||||
|
|
||||||
const queryParams = ref({
|
const queryParams = ref({
|
||||||
size: 5,
|
size: 10,
|
||||||
page: 1,
|
page: 1,
|
||||||
sort: ['spec.pinned,desc', 'spec.publishTime,desc'],
|
sort: ['spec.pinned,desc', 'spec.publishTime,desc'],
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ---------------- 计算属性 ---------------- */
|
/* ---------------- 最新推荐模式(默认/置顶/最新/最旧) ---------------- */
|
||||||
const appInfo = computed(() => {
|
const recommendTabs = [
|
||||||
const appInfoData = haloConfigs.value.appConfig?.appInfo as { name ?: string, logo ?: string } | undefined
|
{ label: '默认', value: 'default' },
|
||||||
return {
|
{ label: '置顶', value: 'pinned' },
|
||||||
name: appInfoData?.name || 'uni-halo',
|
{ label: '最新', value: 'latest' },
|
||||||
logo: checkImageUrl(appInfoData?.logo),
|
{ label: '最旧', value: 'oldest' },
|
||||||
}
|
]
|
||||||
})
|
|
||||||
|
|
||||||
const bloggerInfo = computed(() => {
|
const recommendMode = ref<'default' | 'pinned' | 'latest' | 'oldest'>('default')
|
||||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname ?: string, avatar ?: string } | undefined
|
|
||||||
return {
|
|
||||||
nickname: blogger?.nickname || '',
|
|
||||||
avatar: checkAvatarUrl(blogger?.avatar),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
/** 各模式对应排序参数(默认 = 置顶优先 + 发布时间倒序) */
|
||||||
|
const recommendSortMap: Record<string, string[]> = {
|
||||||
|
default: ['spec.pinned,desc', 'spec.publishTime,desc'],
|
||||||
|
pinned: ['spec.pinned,desc'],
|
||||||
|
latest: ['spec.publishTime,desc'],
|
||||||
|
oldest: ['spec.publishTime,asc'],
|
||||||
|
}
|
||||||
|
|
||||||
const globalAppSettings = computed(() => settingStore.settings)
|
/** 切换推荐模式:重置分页并重新查询 */
|
||||||
|
function handleRecommendModeChange(mode: 'default' | 'pinned' | 'latest' | 'oldest') {
|
||||||
|
if (recommendMode.value === mode)
|
||||||
|
return
|
||||||
|
recommendMode.value = mode
|
||||||
|
isLoadMore.value = false
|
||||||
|
articleList.value = []
|
||||||
|
queryParams.value.page = 1
|
||||||
|
queryParams.value.sort = recommendSortMap[mode]
|
||||||
|
handleGetArticleList()
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 数据加载 ---------------- */
|
/* ---------------- 计算属性 ---------------- */
|
||||||
async function handleQuery() {
|
const appInfo = computed(() => {
|
||||||
handleGetArticleList()
|
const appInfoData = haloConfigs.value.appConfig?.appInfo as { name?: string, logo?: string } | undefined
|
||||||
}
|
return {
|
||||||
|
name: appInfoData?.name || 'uni-halo',
|
||||||
|
logo: checkImageUrl(appInfoData?.logo),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/** 文章列表 */
|
const bloggerInfo = computed(() => {
|
||||||
async function handleGetArticleList() {
|
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
|
||||||
if (calcAuditModeEnabled.value) {
|
return {
|
||||||
// 审核模式:真实文章按 audit-data posts 过滤(数组顺序即展示顺序)
|
nickname: blogger?.nickname || '',
|
||||||
const auditPostNames = appConfigStore.auditData.spec?.posts || []
|
avatar: checkAvatarUrl(blogger?.avatar),
|
||||||
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
|
|
||||||
})
|
|
||||||
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
|
||||||
loadMoreText.value = t('common.noMore')
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error('获取审核文章失败', err)
|
|
||||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
|
||||||
loadMoreText.value = t('common.loadFailed')
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.stopPullDownRefresh()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLoadMore.value) {
|
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
|
||||||
}
|
|
||||||
loadMoreText.value = t('common.loading')
|
|
||||||
|
|
||||||
try {
|
const globalAppSettings = computed(() => settingStore.settings)
|
||||||
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
|
|
||||||
})
|
|
||||||
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
|
||||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
|
||||||
loadMoreText.value = t('common.loadFailed')
|
|
||||||
console.error('获取文章失败', err)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.stopPullDownRefresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 跳转 ---------------- */
|
/* ---------------- 数据加载 ---------------- */
|
||||||
|
async function handleQuery() {
|
||||||
|
handleGetArticleList()
|
||||||
|
}
|
||||||
|
|
||||||
function handleToArticles() {
|
/** 文章列表 */
|
||||||
uni.navigateTo({ url: '/pages-blog/articles/articles' })
|
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
|
||||||
|
})
|
||||||
|
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||||
|
loadMoreText.value = t('common.noMore')
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('获取审核文章失败', err)
|
||||||
|
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||||
|
loadMoreText.value = t('common.loadFailed')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
function handleOnLogoToPage() {
|
if (!isLoadMore.value) {
|
||||||
uni.switchTab({ url: '/pages/tabbar/about/about' })
|
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||||
}
|
}
|
||||||
|
loadMoreText.value = t('common.loading')
|
||||||
|
|
||||||
function handleToTopPage(duration = 500) {
|
try {
|
||||||
uni.pageScrollTo({
|
const res = await getPostList({ ...toRaw(queryParams.value) })
|
||||||
scrollTop: 0,
|
result.value.hasNext = res.data.hasNext
|
||||||
duration,
|
articleList.value = (isLoadMore.value
|
||||||
fail: (err) => {
|
? articleList.value.concat(res.data.items)
|
||||||
console.error('回顶失败', err)
|
: res.data.items).map((item) => {
|
||||||
},
|
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
||||||
})
|
return item
|
||||||
}
|
})
|
||||||
|
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||||
|
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||||
|
loadMoreText.value = t('common.loadFailed')
|
||||||
|
console.error('获取文章失败', err)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
/* ---------------- 跳转 ---------------- */
|
||||||
if (!intercepted.value) {
|
|
||||||
handleQuery()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
init()
|
|
||||||
|
|
||||||
/* ---------------- 生命周期 ---------------- */
|
/** 更多文章:跳转文章列表页 */
|
||||||
|
function handleToArticles() {
|
||||||
|
uni.navigateTo({ url: '/pages-blog/articles/articles' })
|
||||||
|
}
|
||||||
|
|
||||||
// 维护检查
|
function handleOnLogoToPage() {
|
||||||
onShow(async () => {
|
uni.switchTab({ url: '/pages/tabbar/about/about' })
|
||||||
intercepted.value = await interceptOrContinue()
|
}
|
||||||
console.log('拦截状态', intercepted.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
onPullDownRefresh(() => {
|
function handleToTopPage(duration = 500) {
|
||||||
isLoadMore.value = false
|
uni.pageScrollTo({
|
||||||
queryParams.value.page = 1
|
scrollTop: 0,
|
||||||
handleQuery()
|
duration,
|
||||||
})
|
fail: (err) => {
|
||||||
|
console.error('回顶失败', err)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onReachBottom(() => {
|
function init() {
|
||||||
if (calcAuditModeEnabled.value) {
|
if (!intercepted.value) {
|
||||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
handleQuery()
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
if (result.value.hasNext) {
|
init()
|
||||||
queryParams.value.page += 1
|
|
||||||
isLoadMore.value = true
|
/* ---------------- 生命周期 ---------------- */
|
||||||
handleGetArticleList()
|
|
||||||
}
|
// 维护检查
|
||||||
else {
|
onShow(async () => {
|
||||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
intercepted.value = await interceptOrContinue()
|
||||||
}
|
console.log('拦截状态', intercepted.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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') })
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="min-h-screen w-screen flex flex-col bg-page">
|
<view class="min-h-screen w-screen flex flex-col bg-page">
|
||||||
<!-- 轮播 -->
|
<!-- 轮播 -->
|
||||||
<uh-home-banner />
|
<uh-home-banner />
|
||||||
|
|
||||||
<!-- 公告 -->
|
<!-- 公告 -->
|
||||||
<uh-home-notify />
|
<uh-home-notify />
|
||||||
|
|
||||||
<!-- 快捷导航 -->
|
<!-- 快捷导航 -->
|
||||||
<uh-home-quick-nav />
|
<uh-home-quick-nav />
|
||||||
|
|
||||||
<!-- 精选分类 -->
|
<!-- 精选分类 -->
|
||||||
<uh-home-category />
|
<uh-home-category />
|
||||||
|
|
||||||
<!-- 最新文章 -->
|
<!-- 最新文章 -->
|
||||||
<uh-section-title class="mb-4 box-border px-3">
|
<uh-section-title class="mb-4 box-border px-3">
|
||||||
最新推荐
|
最新推荐
|
||||||
<template #right>
|
<template #right>
|
||||||
<view class="uh-global-card-glass flex items-center justify-center gap-x-1 rounded-md p-1 text-gray-400"
|
<view class="flex items-center gap-2">
|
||||||
@click="handleToArticles()">
|
<!-- 推荐模式分段器:默认 / 置顶 / 最新 -->
|
||||||
<wd-icon name="arrow-right" size="12px" />
|
<view class="uh-global-card-glass uh-shadow-xs flex scale-95 items-center gap-1 border rounded-lg p-0.5">
|
||||||
</view>
|
<view
|
||||||
</template>
|
v-for="tab in recommendTabs" :key="tab.value"
|
||||||
</uh-section-title>
|
class="rounded-md px-2 py-0.5 text-xs"
|
||||||
|
:class="recommendMode === tab.value ? 'bg-secondary text-gray-900' : 'text-gray-500'"
|
||||||
|
@click="handleRecommendModeChange(tab.value as 'default' | 'pinned' | 'latest' | 'oldest')"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 更多(查看全部文章) -->
|
||||||
|
<view
|
||||||
|
class="uh-global-card-glass flex items-center justify-center gap-x-1 rounded-md p-1 text-gray-400"
|
||||||
|
@click="handleToArticles()"
|
||||||
|
>
|
||||||
|
<wd-icon name="arrow-right" size="24rpx" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</uh-section-title>
|
||||||
|
|
||||||
<!-- 加载/错误占位 -->
|
<!-- 加载/错误占位 -->
|
||||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
<uh-data-loading
|
||||||
min-height="36vh" @refresh="handleQuery" />
|
v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||||
|
min-height="36vh" @refresh="handleQuery"
|
||||||
|
/>
|
||||||
|
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<view class="box-border flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
|
<view class="box-border 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"
|
<uh-article-card
|
||||||
from="home" :article="article" :audit-mode="calcAuditModeEnabled"
|
v-for="(article, index) in articleList" :key="index"
|
||||||
/>
|
from="home" :article="article" :audit-mode="calcAuditModeEnabled"
|
||||||
</view>
|
/>
|
||||||
<view class="box-border mt-3 pb-5 text-center text-xs text-gray-400">
|
</view>
|
||||||
{{ loadMoreText }}
|
<view class="mt-3 box-border pb-5 text-center text-xs text-gray-400">
|
||||||
</view>
|
{{ loadMoreText }}
|
||||||
</block>
|
</view>
|
||||||
</view>
|
</block>
|
||||||
<uh-notify-dialog />
|
</view>
|
||||||
|
<uh-notify-dialog />
|
||||||
</template>
|
</template>
|
||||||
Reference in New Issue
Block a user