1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-09-13 00:50: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
+147 -14
View File
@@ -1,7 +1,15 @@
<script lang="ts" setup>
defineOptions({
name: 'Index',
})
/**
* 入口页(源自旧项目 pages/index/index.vue,新建复刻)
* 职责:检查插件可用性 → 获取配置 → 二维码 scene 跳文章 → 审计模式 mock → 启动页/首页分流
*/
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getQRCodeInfo } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import { checkJsonAndParse } from '@/utils/json'
definePage({
// 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
type: 'home',
@@ -11,23 +19,148 @@ definePage({
navigationBarTitleText: '初始页面',
},
})
console.log('index/index 首页打印了')
function toHome() {
uni.switchTab({
url: '/pages/tabbar/home/home',
})
/* ---------------- 常量 ---------------- */
const homePagePath = '/pages/tabbar/home/home'
const startPagePath = '/pages/start/start'
const articleDetailPath = '/pages-blog/article-detail/article-detail'
// 本地开发快速跳转页面,发布请置为 false
const DEV_MODE = false
const DEV_TO_TYPE = 'page' as 'page' | 'tabbar'
const DEV_TO_PATH = '/pages-blog/data-visual/data-visual'
/* ---------------- 状态 ---------------- */
const appConfigStore = useAppConfigStore()
const uniHaloPluginId = 'plugin-uni-halo'
const uniHaloPluginAvailableError = '阿偶,检测到当前插件没有安装或者启用,无法启动 uni-halo 哦,请联系管理员'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 逻辑 ---------------- */
/** 检查插件可用性 */
async function handleCheckPluginAvailable(): Promise<boolean> {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
return uniHaloPluginAvailable.value
}
onLoad(() => {
console.log('测试 uni API 自动引入: onLoad')
/** 通过二维码 scene 获取文章 id */
async function getPostIdByQRCode(key: string): Promise<string | null> {
try {
const response = await getQRCodeInfo(key)
if (response.data?.postId)
return response.data.postId as string
}
catch (err) {
console.error('二维码解析失败', err)
}
return null
}
/** 处理审计模式 mock 数据 */
async function handleAuditMode(res: Record<string, unknown>) {
const auditConfig = (res?.auditConfig ?? {}) as {
auditModeEnabled?: boolean
auditModeData?: { jsonUrl?: string, jsonData?: string }
}
if (!auditConfig.auditModeEnabled)
return
if (auditConfig.auditModeData?.jsonUrl) {
await appConfigStore.fetchMockJson()
}
else {
const mockJson = checkJsonAndParse(auditConfig.auditModeData?.jsonData || '')
if (mockJson.ok) {
appConfigStore.setMockJson(mockJson.jsonData as Record<string, unknown>)
}
}
}
/** 启动页/首页分流 */
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) {
if (DEV_TO_TYPE === 'tabbar') {
uni.switchTab({ url: DEV_TO_PATH })
}
else {
uni.navigateTo({ url: DEV_TO_PATH })
}
return
}
// 检查插件
if (!(await handleCheckPluginAvailable()))
return
// 获取配置
try {
const res = await appConfigStore.fetchConfigs()
if (!res) {
uni.switchTab({ url: homePagePath })
return
}
// 二维码 scene 进入:解析 postId 跳文章详情
if (options.scene && options.scene !== '') {
const postId = await getPostIdByQRCode(decodeURIComponent(options.scene))
if (postId) {
uni.redirectTo({
url: `${articleDetailPath}?name=${postId}`,
animationType: 'slide-in-right',
})
return
}
}
// 审计模式 mock
await handleAuditMode(res as Record<string, unknown>)
// 启动页分流
handleCheckShowStarted()
}
catch (err) {
console.error('入口页初始化失败', err)
uni.switchTab({ url: homePagePath })
}
})
</script>
<template>
<view class="box-border bg-white px-4 pt-safe w-screen h-screen flex flex-col items-center justify-center">
uni-halo
<wd-button @click="toHome()">去首页</wd-button>
<view class="app-page h-screen w-screen flex items-center justify-center" style="background-color: #fff;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
:error-text="uniHaloPluginAvailableError"
:use-border="false"
:use-decoration="false"
/>
</view>
</template>
+299
View File
@@ -0,0 +1,299 @@
<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>
+323 -2
View File
@@ -1,13 +1,334 @@
<script lang="ts" setup>
/**
* 关于页(源自旧项目 pages/tabbar/about/about.vue,新建复刻)
* 功能:博主信息 + 站点统计 + 功能导航 + 版权
*/
import { computed, ref, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { getBlogStatistics } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { checkHasAdminLogin } from '@/utils/auth'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import type { IBlogStats } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '关于',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
/* ---------------- 计算属性 ---------------- */
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as
| { nickname?: string, avatar?: string, description?: string }
| undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
description: blogger?.description || '',
}
})
const pageConfig = computed(() => haloConfigs.value.pageConfig?.aboutConfig as
| { bgImageUrl?: string, waveImageUrl?: string }
| undefined)
const calcProfileStyle = computed(() => ({
backgroundImage: `url(${checkImageUrl(pageConfig.value?.bgImageUrl)})`,
}))
const calcWaveUrl = computed(() => checkImageUrl(pageConfig.value?.waveImageUrl))
const basicConfig = computed(() => haloConfigs.value.basicConfig as
| {
copyrightConfig?: { enabled?: boolean, content?: string }
disclaimers?: { enabled?: boolean }
showAboutSystem?: boolean
}
| undefined)
const copyrightConfig = computed(() => basicConfig.value?.copyrightConfig)
const loveEnabled = computed(() => !!(haloConfigs.value.loveConfig as { loveEnabled?: boolean } | undefined)?.loveEnabled)
const socialEnabled = computed(() => !!(haloConfigs.value.authorConfig?.social as { enabled?: boolean } | undefined)?.enabled)
/* ---------------- 状态 ---------------- */
const statisticsShowMore = ref(false)
const statistics = ref<IBlogStats>({ post: 0, comment: 0, category: 0, visit: 0, upvote: 0 })
const navList = ref<{
key: string
title: string
icon: string
iconColor: string
rightText: string
path: string | null
isAdmin?: boolean
openType?: string
show: boolean
}[]>([])
/* ---------------- 功能导航 ---------------- */
async function handleGetNavList() {
let isWx = false
// #ifdef MP-WEIXIN
isWx = true
// #endif
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
navList.value = [
{
key: 'data-visual',
title: '数据看板',
icon: 'chart',
iconColor: '#2196f3',
rightText: '站点数据可视化',
path: '/pages-blog/data-visual/data-visual',
show: dataVisualAvailable,
},
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
icon: 'folder',
iconColor: '#f44336',
rightText: calcAuditModeEnabled.value ? '全部已归档内容' : '全部已归档文章',
path: '/pages-blog/archives/archives',
show: true,
},
{
key: 'love',
title: '恋爱日记',
icon: 'heart',
iconColor: '#f44336',
rightText: '博主的恋爱日记',
path: '/pages-blog/love/love',
show: loveEnabled.value,
},
{
key: 'vote',
title: '投票中心',
icon: 'box',
iconColor: '#f44336',
rightText: '查看和进行投票',
path: '/pages-blog/votes/votes',
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
},
{
key: 'friend-links',
title: '友情链接',
icon: 'link',
iconColor: '#2196f3',
rightText: '看看博主朋友们吧',
path: '/pages-blog/friend-links/friend-links',
show: calcLinksPluginEnabled.value,
},
{
key: 'disclaimers',
title: '免责声明',
icon: 'map',
iconColor: '#f44336',
rightText: '博客内容免责声明',
path: '/pages-blog/disclaimers/disclaimers',
show: !!basicConfig.value?.disclaimers?.enabled,
},
{
key: 'contact-blogger',
title: '联系博主',
icon: 'message',
iconColor: '#ff9800',
rightText: '博主常用联系方式',
path: '/pages-blog/contact/contact',
show: socialEnabled.value,
},
{
key: 'about',
title: '关于项目',
icon: 'info',
iconColor: '#2196f3',
rightText: '小莫唐尼开源项目',
path: '/pages-blog/about/about',
show: !!basicConfig.value?.showAboutSystem,
},
]
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
try {
const res = await getBlogStatistics()
statistics.value = res.data
}
catch (err) {
console.error('获取统计失败', err)
uni.showToast({ icon: 'none', title: t('common.loadFailedRetry') })
}
finally {
uni.stopPullDownRefresh()
}
}
/* ---------------- 交互 ---------------- */
function handleOnNav(data: { path: string | null, isAdmin?: boolean }) {
const { path, isAdmin } = data
if (!path)
return
// 拦截后台管理页面(需超管登录)
if (isAdmin && !checkHasAdminLogin()) {
uni.showModal({
title: '提示',
content: '未登录超管账号或登录状态已过期,是否立即登录?',
showCancel: true,
cancelText: '否',
cancelColor: '#999999',
confirmText: '是',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/auth/login' })
}
},
})
return
}
uni.navigateTo({ url: path })
}
/* ---------------- 生命周期 ---------------- */
watch(haloConfigs, () => {
handleGetNavList()
}, { deep: true, immediate: true })
handleGetData()
onPullDownRefresh(() => {
handleGetData()
})
</script>
<template>
<view class="mt-10 text-center text-green-500">
关于页面
<view class="app-page min-h-screen w-screen pb-6">
<!-- 博主信息 -->
<view class="blogger-info relative h-[600rpx] w-full" :style="[calcProfileStyle]">
<image class="avatar absolute left-1/2 top-[200rpx] z-2 h-[130rpx] w-[130rpx] border-6 border-white rounded-full -translate-x-1/2" :src="bloggerInfo.avatar" mode="aspectFill" />
<view class="profile absolute left-0 top-[340rpx] z-6 w-full text-center text-white">
<view class="author text-[34rpx] font-bold">
{{ bloggerInfo.nickname }}
</view>
<view class="desc mt-4 px-12 text-[26rpx] opacity-90">
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
</view>
</view>
<image v-if="calcWaveUrl" :src="calcWaveUrl" mode="scaleToFill" class="gif-wave absolute bottom-0 left-0 z-99 h-[100rpx] w-full" style="mix-blend-mode: screen;" />
</view>
<!-- 统计信息 -->
<view class="statistics-wrap overflow-hidden rounded-b-3xl bg-white shadow-sm">
<view class="statistics flex pb-3 pt-3">
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #ff9800;">
{{ statistics.post }}
</view>
<view class="mt-1 text-center text-[24rpx] text-[#999]">
内容数量
</view>
</view>
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #4caf50;">
{{ statistics.visit }}
</view>
<view class="mt-1 text-[24rpx] text-[#999]">
访客数量
</view>
</view>
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #2196f3;">
{{ statistics.category }}
</view>
<view class="mt-1 text-center text-[24rpx] text-[#999]">
分类总数
</view>
</view>
</view>
<view v-if="statisticsShowMore" class="statistics flex border-t-2 border-[#fafafa] pb-3 pt-3">
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #ff9800;">
{{ statistics.comment }}
</view>
<view class="mt-1 text-center text-[24rpx] text-[#999]">
评论数量
</view>
</view>
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #2196f3;">
{{ statistics.upvote }}
</view>
<view class="mt-1 text-[24rpx] text-[#999]">
点赞数量
</view>
</view>
</view>
<view class="show-more-btn pb-4 text-center text-[24rpx] text-[#999]" @click="statisticsShowMore = !statisticsShowMore">
{{ statisticsShowMore ? '收起' : '展开' }}
</view>
</view>
<!-- 功能导航 -->
<view class="nav-wrap mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<template v-for="nav in navList.filter(n => n.show)" :key="nav.key">
<view class="nav-item flex items-center justify-between border-b-2 border-[#f5f5f5] px-3 py-7" @click="handleOnNav(nav)">
<view class="nav-left flex items-center gap-4">
<wd-icon :name="nav.icon" size="18px" :color="nav.iconColor" />
<text class="nav-title text-[28rpx] text-[#303133]">{{ nav.title }}</text>
</view>
<view class="nav-right flex items-center gap-2">
<text class="nav-right-text text-[24rpx] text-[#c0c4cc]">{{ nav.rightText }}</text>
<wd-icon name="arrow-right" size="12px" color="#c0c4cc" />
</view>
</view>
</template>
</view>
<!-- 版权 -->
<view v-if="copyrightConfig?.enabled" class="copyright mt-10 px-6 text-center text-[22rpx] text-[#c0c4c7]">
<view>{{ copyrightConfig.content }}</view>
</view>
</view>
</template>
<style scoped lang="scss">
.app-page {
.blogger-info {
background-size: cover;
background-repeat: no-repeat;
&::before {
content: '';
width: 100%;
height: 100%;
position: absolute;
background-color: rgb(0 0 0 / 30%);
z-index: 0;
}
}
.nav-wrap {
.nav-item {
&:last-child {
border-bottom: none;
}
}
}
}
</style>
+365 -2
View File
@@ -1,13 +1,376 @@
<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'
definePage({
style: {
navigationBarTitleText: '分类',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const mockJson = computed(() => appConfigStore.mockJson)
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
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 calcShowType = computed(() => currentCategoryConfig.value.type)
/* ---------------- 视图切换 ---------------- */
function handleChangeShowType() {
currentCategoryConfig.value.type = calcShowType.value === 'list-post' ? 'list' : 'list-post'
handleInitPage()
}
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 = ''
}
function handleInitPage() {
handleResetInit()
if (calcShowType.value === 'list-post') {
queryParams.value.size = 99999
}
handleGetData()
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
currentCategoryConfig.value.type = 'list'
const categoryMock = mockJson.value.category as { list?: { title?: string, cover?: string }[] } | undefined
dataList.value = (categoryMock?.list || []).map(item => ({
metadata: { name: String(Date.now() * Math.random()) },
spec: {
displayName: item.title || '',
slug: '',
priority: 0,
cover: checkThumbnailUrl(item.cover, true),
},
postCount: 0,
}))
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
return
}
uni.showLoading({ mask: true, title: t('common.loading') })
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
try {
const res = await getCategoryList({ ...queryParams.value })
if (calcShowType.value === 'list') {
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext
const tempItems = res.data.items.map(item => ({
...item,
postCount: item.postCount ?? 0,
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
}))
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)
}
}
/** 获取当前分类下的文章 */
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()
}
function handleToCategory(category: ICategory) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
function 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') })
}
})
</script>
<template>
<view class="mt-10 text-center text-green-500">
分类
<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 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>
</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>
+266 -2
View File
@@ -1,13 +1,277 @@
<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 } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '图库',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const mockJson = computed(() => appConfigStore.mockJson)
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const galleryConfig = computed(() => haloConfigs.value.pageConfig?.galleryConfig)
/** 依赖插件(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)
/* ---------------- 数据加载 ---------------- */
async function handleGetCategory() {
if (calcAuditModeEnabled.value) {
handleGetData(true)
return
}
try {
const res = await getPhotoGroupList({ page: 1, size: 0 })
category.value.list = (res.data.items || [])
.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 handleGetData(isClearList = false) {
if (calcAuditModeEnabled.value) {
const galleryMock = mockJson.value.gallery as { list?: string[] } | undefined
dataList.value = (galleryMock?.list || []).map(item => ({
metadata: { name: String(Date.now() * Math.random()) },
spec: {
displayName: '',
url: checkImageUrl(item),
},
}))
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
lock.value = false
return
}
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)
}
}
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 handleOnCategoryChange(e: { detail: { current: number } }) {
if (lock.value)
return
handleGetDataByCategory(e.detail.current)
}
/* ---------------- 图片预览 ---------------- */
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
}
})
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)
})
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="mt-10 text-center text-green-500">
相册图库
<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>
<!-- 顶部切换 -->
<view v-if="category.list.length > 0" class="category-tabs fixed inset-x-0 top-0 z-6 bg-white">
<wd-tabs
v-model="category.activeIndex"
:tabs="category.list.map(item => ({ title: item.displayName }))"
align="left"
@change="handleOnCategoryChange"
/>
</view>
<!-- 占位区域 -->
<view v-if="category.list.length > 0" class="h-[90rpx] w-screen" />
<!-- 骨架屏 -->
<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 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>
+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>
+368 -2
View File
@@ -1,13 +1,379 @@
<script lang="ts" setup>
/**
* 瞬间页(源自旧项目 pages/tabbar/moments/moments.vue,新建复刻)
* 功能:瞬间卡片列表(头像/内容/图片/音频/视频/标签) + 分页加载
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getMomentList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import { markdownConfig } from '@/config/markdown'
import type { IMoment } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '瞬间',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const mockJson = computed(() => appConfigStore.mockJson)
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const startConfig = computed(() => haloConfigs.value.appConfig?.startConfig as { title?: string } | undefined)
/** 依赖插件(plugin-moments) */
const uniHaloPluginId = 'plugin-moments'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 1 })
const hasNext = ref(false)
const dataList = ref<(IMoment & { images?: { type?: string, url: string }[], videos?: { id?: string, url: string }[], audios?: { type?: string, url: string }[], spec: { newHtml?: string } })[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null)
/** 移除内容中的 tag 链接 */
function removeTagLinksCompletely(htmlString: string): string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
return htmlString.replace(regex, '')
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
const momentsMock = mockJson.value.moments as { list?: { content?: string, time?: string, images?: string[] }[] } | undefined
dataList.value = (momentsMock?.list || []).map(item => ({
metadata: { name: String(Date.now() * Math.random()) },
spec: {
content: item.content || '',
owner: {
displayName: bloggerInfo.value.nickname,
avatar: bloggerInfo.value.avatar,
},
visible: 'PUBLIC',
allowComment: true,
approved: true,
releaseTime: item.time,
},
images: (item.images || []).map(img => ({ type: 'PHOTO', url: checkThumbnailUrl(img) })),
videos: [],
}))
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
return
}
uni.showLoading({ mask: true, title: t('common.loading') })
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
try {
const res = await getMomentList({ ...queryParams.value })
console.log('获取瞬间数据成功', res)
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext
const tempItems = res.data.items
.filter(x => x.spec.visible === 'PUBLIC')
.map((item) => {
const medium = (item.spec as unknown as { medium?: { type?: string, url: string }[] }).medium || []
const newItem = {
...item,
spec: {
...item.spec,
owner: {
displayName: bloggerInfo.value.nickname,
avatar: bloggerInfo.value.avatar,
},
newHtml: removeTagLinksCompletely((item.spec as unknown as { content?: { html?: string } }).content?.html || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
audios: medium.filter(x => x.type === 'AUDIO'),
}
return newItem
})
dataList.value = isLoadMore.value
? dataList.value.concat(tempItems)
: tempItems
nextTick(() => {
createVideoContexts(tempItems)
})
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
/* ---------------- 视频互斥 ---------------- */
function createVideoContexts(list: { videos?: { id?: string }[] }[]) {
stopAllVideos()
list.map(item => item.videos || []).flat().forEach((item) => {
if (item.id) {
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
}
})
}
function stopAllVideos(excludesVideoId: string | null = null) {
Object.keys(videoContexts.value).forEach((videoId) => {
if (!excludesVideoId || excludesVideoId !== videoId) {
videoContexts.value[videoId]?.pause()
}
})
}
function onVideoPlay(videoId: string) {
currentVideoId.value = videoId
stopAllVideos(videoId)
}
function onVideoPause(videoId: string) {
if (currentVideoId.value === videoId) {
currentVideoId.value = null
}
}
function onVideoEnded() {
currentVideoId.value = null
}
/* ---------------- 交互 ---------------- */
function handlePreview(index: number, list: { url: string }[]) {
uni.previewImage({
current: index,
urls: list.map(item => item.url),
})
}
function handleToMomentDetail(moment: IMoment) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/** 格式化瞬间时间 */
function formatMomentTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uni.setNavigationBarTitle({ title: t('page.moments.title') })
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
videoContexts.value = {}
currentVideoId.value = null
handleGetData()
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
</script>
<template>
<view class="mt-10 text-center text-green-500">
瞬间
<view class="app-page box-border min-h-screen w-screen flex flex-col py-6">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用瞬间功能哦请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else>
<view v-if="loading !== 'success'" class="loading-wrap p-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<view v-else class="app-page-content">
<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="moment-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white shadow-sm">
<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" />
<view class="nickname ml-3">
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
{{ moment.spec.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1 text-[24rpx] text-[#666]">
{{ formatMomentTime(moment.spec.releaseTime) }}
</view>
</view>
</view>
<view class="moment-content px-3 py-2" @click.stop="handleToMomentDetail(moment)">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</view>
<!-- 图片 -->
<view v-if="moment.images && moment.images.length !== 0" class="images flex flex-wrap items-start px-3 pb-6" :class="`images-${moment.images.length}`">
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex" class="image-item box-border p-1" :class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
<image
mode="aspectFill"
class="image-src h-full w-full rounded-lg"
:src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])"
/>
</view>
</view>
<!-- 音频 -->
<view v-if="moment.audios && moment.audios.length !== 0" class="audio-list mb-3 flex flex-col gap-3 px-3">
<uh-audio-player
v-for="audio in moment.audios"
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${startConfig?.title || bloggerInfo.nickname}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
<!-- 视频 -->
<view v-if="moment.videos && moment.videos.length !== 0" class="video-list mb-3 flex flex-col gap-3 px-3">
<video
v-for="(video, index) in moment.videos"
:id="`video_${video.id}`"
:key="index"
class="video-src h-[400rpx] w-full rounded-xl"
:src="video.url"
:show-mute-btn="true"
:controls="true"
:show-center-play-btn="true"
:enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')"
@pause="onVideoPause(video.id || '')"
@ended="onVideoEnded"
/>
</view>
<!-- 标签 -->
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-4 px-3 pb-6">
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="tag text-[24rpx]" :style="{ color: randomTagColor() }">
{{ tag }}
</view>
</view>
</view>
<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()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view class="load-text pb-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.moment-card {
.head {
.nickname {
.nickname-text {
/* 无额外样式 */
}
}
}
}
</style>