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

chore: 批量新增项目依赖、工具函数、页面与组件资源

1. 新增mp-html、qs等生产依赖,补全项目基础库
2. 新增平台判断、缓存、工具函数等通用工具集
3. 新增标签页、网站浏览页、关于页等业务页面
4. 新增分类卡片、通知弹窗等业务组件
5. 新增uts-progressNotification、liu-poster、uhalo-upgrade等uni模块
6. 补充audio/video组件样式补件,修复uni-components路径缺失问题
7. 新增环境变量Halo个人令牌配置项
8. 重构store导出结构,新增appConfig/halo/setting三个状态模块
9. 新增tsconfig编译目标配置,适配更高版本ES语法
This commit is contained in:
小莫唐尼
2026-08-31 19:56:16 +08:00
parent ba5b77568b
commit 067f3ed98a
147 changed files with 20866 additions and 325 deletions
+549 -3
View File
@@ -1,14 +1,560 @@
<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, checkThumbnailUrl } 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'
definePage({
style: {
navigationBarTitleText: '首页',
enablePullDownRefresh: true,
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const haloConfigs = computed(() => appConfigStore.configs)
const mockJson = computed(() => appConfigStore.mockJson)
/* ---------------- 状态 ---------------- */
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 bannerList = ref<IBannerItem[]>([])
const result = ref<{ hasNext: boolean }>({ hasNext: false })
const notify = ref({
show: false,
data: {} as IBannerItem,
})
const queryParams = ref({
size: 5,
page: 1,
sort: ['spec.pinned,desc', 'spec.publishTime,desc'],
})
/* ---------------- 计算属性 ---------------- */
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 bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const calcIsShowQuickNavigationEnabled = computed(() => !!haloConfigs.value.pageConfig?.homeConfig?.useQuickNavigation)
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 bannerConfig = computed(() => haloConfigs.value.pageConfig?.homeConfig?.bannerConfig)
const globalAppSettings = computed(() => settingStore.settings)
/** 快捷导航列表(由配置控制显隐) */
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 handleQuery() {
handleGetBanner()
await Promise.all([handleGetArticleList(), handleGetCategoryList()])
}
/** 轮播图 */
function handleGetBanner() {
if (calcAuditModeEnabled.value) {
const homeMock = mockJson.value.home as { bannerList?: { title?: string, cover?: string, time?: string }[] } | undefined
bannerList.value = (homeMock?.bannerList || []).map(item => ({
id: Date.now() * Math.random(),
title: item.title,
image: checkThumbnailUrl(item.cover),
src: checkThumbnailUrl(item.cover),
type: 'custom',
content: '',
url: '',
}))
return
}
if (!bannerConfig.value?.enabled)
return
if (bannerConfig.value.type === 'custom') {
bannerList.value = (bannerConfig.value.list as { title?: string, cover?: string, content?: string, url?: string }[]).map(item => ({
id: Date.now() * Math.random(),
title: item.title,
image: checkThumbnailUrl(item.cover),
src: checkThumbnailUrl(item.cover),
type: 'custom',
content: item.content || '',
url: item.url || '',
}))
return
}
// post 类型:取最新文章作为轮播
const list = articleList.value.slice(0, 5).map(item => ({
id: item.metadata.name,
title: item.spec.title,
image: checkThumbnailUrl(item.spec.cover),
src: checkThumbnailUrl(item.spec.cover),
type: 'post',
content: item.status?.excerpt || '',
url: '',
}))
bannerList.value = list
}
/** 精选分类 */
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)
}
}
/** 文章列表 */
async function handleGetArticleList() {
if (calcAuditModeEnabled.value) {
const homeMock = mockJson.value.home as { postList?: { title?: string, cover?: string, time?: string, desc?: string }[] } | undefined
articleList.value = (homeMock?.postList || []).map(item => ({
metadata: { name: String(Date.now() * Math.random()) },
spec: {
title: item.title || '',
slug: '',
cover: item.cover,
pinned: false,
publishTime: item.time,
deleted: false,
publish: true,
allowComment: true,
visible: 'PUBLIC' as const,
priority: 0,
categories: [],
tags: [],
},
status: { permalink: '', inProgress: false, excerpt: item.desc },
stats: { visit: 0 },
}))
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
return
}
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
try {
const res = await getPostList({ ...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')
// post 型轮播依赖文章列表,若启用则刷新
if (bannerConfig.value?.enabled && bannerConfig.value.type !== 'custom') {
handleGetBanner()
}
}
catch (err) {
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
console.error('获取文章失败', err)
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
/* ---------------- 跳转 ---------------- */
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 handleToCategoryPage() {
uni.switchTab({ url: '/pages/tabbar/category/category' })
}
function handleToArticlesPage() {
uni.navigateTo({ url: '/pages-blog/articles/articles' })
}
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/articles/articles' })
}
function handleOnLogoToPage() {
uni.switchTab({ url: '/pages/tabbar/about/about' })
}
function handleClickNav(item: { path: string }) {
uni.navigateTo({ url: item.path })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function handleOnBannerClick(item: IBannerItem) {
if (calcAuditModeEnabled.value)
return
if (item.type === 'custom') {
if (item.content) {
notify.value = { show: true, data: item }
return
}
if (item.url) {
uni.navigateTo({
url: `/pages-blog/website/website?data=${JSON.stringify({
title: item.title || t('common.loading'),
url: encodeURIComponent(item.url),
})}`,
})
}
return
}
if (!item.id)
return
handleToArticleDetail({ metadata: { name: String(item.id) } } as IPost)
}
function handleOnNotifyChange(show: boolean) {
notify.value.show = show
}
/* ---------------- 生命周期 ---------------- */
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()
</script>
<template>
<view class="mt-10 text-center text-green-500">
首页
<view class="app-page 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 v-if="loading !== 'success' && articleList.length === 0" class="loading-wrap px-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<block v-else>
<!-- 轮播 Banner -->
<view v-if="bannerConfig?.enabled" class="bg-white pb-6">
<view v-if="bannerList.length !== 0" 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"
:show-title="bannerConfig.showTitle"
:type="bannerConfig.type"
:list="bannerList"
@on-click="handleOnBannerClick"
/>
</view>
</view>
<!-- 快捷导航 -->
<view v-if="calcIsShowQuickNavigationEnabled && navList.filter(x => x.show).length" class="nav-box mx-6 mb-6 mt-4 overflow-hidden rounded-xl bg-white p-3">
<view class="page-item-title font-bold">
快捷导航
</view>
<view class="nav-list grid grid-cols-4 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>
<!-- 精选分类 -->
<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="handleToArticlesPage">
<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>
<!-- 通知弹窗 -->
<uh-notify-dialog
v-if="notify.show"
:show="notify.show"
:title="notify.data.title || ''"
:content="notify.data.content || ''"
:url="notify.data.url || ''"
@on-change="handleOnNotifyChange"
/>
</view>
</template>
<style scoped lang="scss">
.app-page {
display: flex;
flex-direction: column;
}
.header {
.logo {
flex-shrink: 0;
}
.search-input {
.search-text {
margin-left: 12rpx;
}
}
}
.page-item-title {
position: relative;
padding-left: 24rpx;
font-size: 32rpx;
color: #303133;
&::before {
content: '';
position: absolute;
left: 0;
top: 8rpx;
width: 8rpx;
height: 30rpx;
background-color: rgb(33 150 243);
border-radius: 6rpx;
}
}
.show-more {
width: 42rpx;
height: 42rpx;
box-shadow: 0 0 24rpx rgb(0 0 0 / 3%);
}
.to-top-btn {
position: fixed;
right: 24rpx;
bottom: 120rpx;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgb(0 0 0 / 10%);
z-index: 6;
}
</style>