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

feat: 新增收藏功能与多项体验优化

- 新增本地收藏store与工具函数,支持文章/瞬间收藏持久化
- 重构插件可用性检查hook,统一管理依赖插件状态
- 替换旧数据加载hook为新的状态管理方案
- 优化首页布局与加载逻辑,调整公告组件样式
- 为瞬间详情页添加点赞、评论与收藏功能
- 新增文本处理工具,支持HTML/Markdown转纯文本与摘要截断
- 修复评论弹窗适配瞬间类型的参数问题
This commit is contained in:
小莫唐尼
2026-09-04 17:17:18 +08:00
parent 629b1f9219
commit 62b604644c
28 changed files with 1548 additions and 1387 deletions
+95 -48
View File
@@ -16,7 +16,7 @@ license: Complete terms in LICENSE.txt
- **定义或修改 API**(约定使用 alova,格式、`meta` 传参、类型文件位置) - **定义或修改 API**(约定使用 alova,格式、`meta` 传参、类型文件位置)
- **定义或修改类型**`src/api/types/` 下的接口与请求/响应类型) - **定义或修改类型**`src/api/types/` 下的接口与请求/响应类型)
- **使用通用配置**`src/config/` 的 appConfig / appSettings / haloGlobal / markdown - **使用通用配置**`src/config/` 的 appConfig / appSettings / haloGlobal / markdown
- **页面数据请求**`useDataLoading` / `useRequest` hooks + `uh-data-loading` 组件) - **页面数据请求**`useDataLoadingStatus` + `updateLoadingStatus` 管理四态 + `uh-data-loading` 组件)
- **处理多平台差异**(H5 / 微信小程序 / APP,条件编译) - **处理多平台差异**(H5 / 微信小程序 / APP,条件编译)
## 三条铁律(先记住) ## 三条铁律(先记住)
@@ -295,81 +295,128 @@ onLoad(() => {
- 页面根节点用 `app-page` 类 + 主题底色:`<view class="app-page min-h-screen w-screen flex flex-col bg-page">` - 页面根节点用 `app-page` 类 + 主题底色:`<view class="app-page min-h-screen w-screen flex flex-col bg-page">`
- 页面标题 `navigationBarTitleText` 写中文;下拉刷新 `enablePullDownRefresh: true` - 页面标题 `navigationBarTitleText` 写中文;下拉刷新 `enablePullDownRefresh: true`
### 5.4 页面数据请求(统一 hooks + uh-data-loading 组件) ### 5.4 页面数据请求(统一 updateLoadingStatus + uh-data-loading 组件)
**数据加载四态**`loading / error / empty / success`由 `useDataLoading` hook 接管, **数据加载四态**`loading / error / empty / success`统一用
页面只提供请求函数,**取代「手工 ref + try/catch 逐个搬运状态」的写法**。 `useDataLoadingStatus``src/hooks/useDataLoadingStatus.ts`)的
`updateLoadingStatus(DataLoadingStatusEnum.Xxx)` 管理,**取代「手工 ref + try/catch 逐个搬运状态」的写法**。
首页、图库、瞬间、分类等 tabbar 页面均为此写法(参考 `src/pages/tabbar/category/category.vue`),
**后续所有页面请求状态一律照此管理**。
```vue ```vue
<script lang="ts" setup> <script lang="ts" setup>
import { onLoad } from '@dcloudio/uni-app' import { computed, ref } from 'vue'
import { getMomentByName } from '@/api/halo' import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { useDataLoading } from '@/hooks/useDataLoading' import { getCategoryList } from '@/api/halo'
import type { IMoment } from '@/api/types/halo' import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { ICategory } from '@/api/types/halo'
definePage({ definePage({
style: { style: {
navigationBarTitleText: '瞬间详情', navigationBarTitleText: '分类',
enablePullDownRefresh: true, enablePullDownRefresh: true,
backgroundColor: '#f6f3ee', // 下拉露出的窗口底色对齐页面底色 backgroundColor: '#f6f3ee',
}, },
}) })
const { data: moment, status, run: loadMoment } = useDataLoading( /* ---------------- 状态 ---------------- */
async (): Promise<IMoment> => { const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const res = await getMomentByName(queryName.value) const queryParams = ref({ size: 20, page: 1 })
return res.data const hasNext = ref(false)
}, const dataList = ref<ICategory[]>([])
{ const isLoadMore = ref(false)
onSuccess: (data) => { const loadMoreText = ref(t('common.loading'))
// 数据就绪后的处理
},
onError: () => {
// 失败处理(run 已捕获异常,不会向外抛出)
},
},
)
onLoad(() => { /* ---------------- 数据加载 ---------------- */
loadMoment() async function handleGetData() {
}) updateLoadingStatus(DataLoadingStatusEnum.Loading) // 请求开始
if (!isLoadMore.value) {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
}
loadMoreText.value = t('common.loading')
onPullDownRefresh(async () => { try {
await loadMoment() const res = await getCategoryList({ ...queryParams.value })
hasNext.value = res.data.hasNext
dataList.value = isLoadMore.value
? dataList.value.concat(res.data.items)
: res.data.items
// 请求结束:按数据是否为空区分 success / empty
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
}
catch (err) {
console.error(err)
updateLoadingStatus(DataLoadingStatusEnum.Error) // 失败
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
}, 500)
}
}
function handleResetInit() {
dataList.value = []
queryParams.value.page = 1
hasNext.value = false
isLoadMore.value = false
loadMoreText.value = t('common.loading')
}
onMounted(() => {
handleResetInit()
handleGetData()
})
onPullDownRefresh(() => {
handleResetInit()
handleGetData()
})
onReachBottom(() => {
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
}) })
</script> </script>
<template> <template>
<view class="app-page box-border min-h-screen w-screen bg-page px-4 pb-8 pt-4"> <view class="box-border min-h-screen w-screen flex flex-col bg-page p-3">
<!-- 状态区:loading/error/empty 由 uh-data-loading 展示,refresh 触发重新请求 --> <!-- 状态区:loading/error/empty 由 uh-data-loading 展示,refresh 触发重新请求 -->
<uh-data-loading <uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus" />
v-if="status !== 'success'"
:loading-status="status"
min-height="60vh"
error-text="瞬间内容加载失败"
empty-text="瞬间不存在或已被删除"
@refresh="loadMoment"
/>
<!-- 成功态:渲染数据 --> <!-- 成功态:渲染数据 -->
<template v-else> <block v-else>
<view>{{ moment?.spec.releaseTime }}</view> <view v-for="item in dataList" :key="item.metadata.name" class="uh-global-card-glass rounded-xl p-4">
</template> {{ item.spec.displayName }}
</view>
<view class="w-full py-5 text-center text-xs text-gray-400">
{{ loadMoreText }}
</view>
</block>
</view> </view>
</template> </template>
``` ```
**要点** **要点**
- `useDataLoading(fetcher, { isEmpty, onSuccess, onError })` 返回 `{ data, status, run }` - `useDataLoadingStatus()` 返回 `{ loadingStatus, updateLoadingStatus }``DataLoadingStatusEnum` 四态:
- `isEmpty` 自定义判空(默认:数组看长度、对象看键数、空值视为空) `Loading / Error / Empty / Success`
- `run` 已捕获异常,失败置 `status='error'`,不会向外抛出 - 请求开始置 `DataLoadingStatusEnum.Loading`;成功按数据是否为空置 `Success` / `Empty`;失败置 `Error`
- 模板里 `v-if="status !== 'success'"` 显示 `<uh-data-loading>`,否则渲染数据 - 模板里 `v-if="loadingStatus !== DataLoadingStatusEnum.Success"` 显示 `<uh-data-loading>`,否则渲染数据
- `<uh-data-loading>` 常用 props`loading-status`(必传)、`min-height`、`loading-text`、 - `<uh-data-loading>` 常用 props`loading-status`(必传)、`min-height`、`loading-text`、
`error-text`、`empty-text`(留空显示默认文案);`@refresh` 绑重试函数 `error-text`、`empty-text`(留空显示默认文案);`@refresh` 绑重试函数
- **旧页面**用的 `useDataLoadingStatus``DataLoadingStatusEnum`)已标记 `@deprecated` - 首页特例:入口拦截 + 文章列表空时用 `v-if="loadingStatus !== Success && articleList.length === 0"`
新代码一律用 `useDataLoading` 避免轮播/公告区被占位组件顶掉
- 详情页等单数据场景(如 `moment-detail`)可用 `useDataLoading` 状态机(返回 `{ data, status, run }`),
列表页/四态展示优先 `updateLoadingStatus` 写法
- 简单场景也可用 `useRequest(fn, { immediate })`:返回 `{ loading, error, data, run }` - 简单场景也可用 `useRequest(fn, { immediate })`:返回 `{ loading, error, data, run }`
### 5.5 列表页(分页加载) ### 5.5 列表页(分页加载)
@@ -640,7 +687,7 @@ loadMoreText.value = t('common.loadMore')
- 新 hooks 放 `src/hooks/`auto-import`unplugin-auto-import` 已配 `dirs: ['src/hooks']`),页面**免 import 直接调用** - 新 hooks 放 `src/hooks/`auto-import`unplugin-auto-import` 已配 `dirs: ['src/hooks']`),页面**免 import 直接调用**
- 命名 `useXxx`;有配套单测的写 `xxx.test.ts`(如 `useDataLoading.test.ts`、`useRequest.test.ts` - 命名 `useXxx`;有配套单测的写 `xxx.test.ts`(如 `useDataLoading.test.ts`、`useRequest.test.ts`
- 参考既有实现风格:`useDataLoading`状态机四态)、`useScroll`、`useUpload`(平台条件编译处理) - 参考既有实现风格:`useDataLoadingStatus`(页面四态管理,见 §5.4)、`useDataLoading`(详情页单数据状态机)、`useScroll`、`useUpload`(平台条件编译处理)
### 8.6 Git 提交与合入 ### 8.6 Git 提交与合入
@@ -10,9 +10,12 @@
isComment ?: boolean isComment ?: boolean
title ?: string title ?: string
postName : string postName : string
/** 评论目标 kind(文章 Post / 瞬间 Moment) */
subjectKind ?: string
}>(), { }>(), {
isComment: false, isComment: false,
title: '', title: '',
subjectKind: 'Post',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -189,7 +192,7 @@
}, },
subjectRef: { subjectRef: {
group: 'content.halo.run', group: 'content.halo.run',
kind: 'Post', kind: props.subjectKind,
name: form.value.postName, name: form.value.postName,
version: 'v1alpha1', version: 'v1alpha1',
}, },
@@ -18,8 +18,8 @@ interface IProps {
const props = withDefaults(defineProps<IProps>(), { const props = withDefaults(defineProps<IProps>(), {
loadingStatus: 'loading', loadingStatus: 'loading',
minHeight: '60vh', minHeight: '75vh',
loadingText: '稍等,正在加载中哦...', loadingText: '稍等,正在加载中哦',
errorText: '哎呀,加载失败了呢~', errorText: '哎呀,加载失败了呢~',
emptyText: '啊偶,暂时没有数据呢~', emptyText: '啊偶,暂时没有数据呢~',
loadingSubText: '', loadingSubText: '',
@@ -63,7 +63,7 @@ const statusScene = computed(() => {
<template> <template>
<view <view
class="w-full flex flex-col items-center justify-center gap-y-8 text-sm" class="w-full flex flex-col items-center justify-center gap-y-4 text-sm"
:style="{ minHeight: props.minHeight }" :style="{ minHeight: props.minHeight }"
> >
<!-- 状态舞台:光晕 + 漂浮装饰点 + 毛玻璃表情珠 --> <!-- 状态舞台:光晕 + 漂浮装饰点 + 毛玻璃表情珠 -->
@@ -83,8 +83,8 @@ const statusScene = computed(() => {
<view class="flex items-center justify-center text-[28rpx] font-bold" :class="statusScene.mainTextClass"> <view class="flex items-center justify-center text-[28rpx] font-bold" :class="statusScene.mainTextClass">
<text>{{ statusScene.mainText }}</text> <text>{{ statusScene.mainText }}</text>
<!-- 加载中三点跳动 --> <!-- 加载中三点跳动 -->
<view v-if="isLoading" class="ml-3 flex items-end gap-1"> <view v-if="isLoading" class="ml-1 flex items-end gap-1">
<view v-for="n in 3" :key="n" class="typing-dot" /> <view v-for="n in 3" :key="n" class="typing-dot bg-primary" />
</view> </view>
</view> </view>
<text v-if="statusScene.subText" class="mt-3 text-[24rpx] text-gray-400"> <text v-if="statusScene.subText" class="mt-3 text-[24rpx] text-gray-400">
@@ -98,9 +98,6 @@ const statusScene = computed(() => {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
/* 氛围化状态占位:keyframes/状态配色无法用原子类表达,保留 scoped 样式 */
/* —— 毛玻璃表情珠(三态共用,缓慢上下漂浮) —— */
.bubble { .bubble {
animation: bubble-float 2s ease-in-out infinite; animation: bubble-float 2s ease-in-out infinite;
} }
@@ -109,7 +106,6 @@ const statusScene = computed(() => {
display: inline-block; display: inline-block;
} }
/* —— 光晕(状态色,缓慢呼吸) —— */
.glow { .glow {
animation: glow-pulse 2.4s ease-in-out infinite; animation: glow-pulse 2.4s ease-in-out infinite;
} }
@@ -126,7 +122,6 @@ const statusScene = computed(() => {
background: rgba(217, 249, 157, 0.5); background: rgba(217, 249, 157, 0.5);
} }
/* —— 漂浮装饰点(品牌双色,错峰漂浮) —— */
.deco-dot { .deco-dot {
animation: deco-float 2s ease-in-out infinite; animation: deco-float 2s ease-in-out infinite;
} }
@@ -168,7 +163,6 @@ const statusScene = computed(() => {
width: 10rpx; width: 10rpx;
height: 10rpx; height: 10rpx;
border-radius: 50%; border-radius: 50%;
background: rgba(163, 230, 53, 0.95);
animation: dot-jump 1s ease-in-out infinite; animation: dot-jump 1s ease-in-out infinite;
&:nth-child(2) { &:nth-child(2) {
@@ -1,269 +0,0 @@
<script lang="ts" setup>
/**
* 轮播组件(源自旧项目 components/e-swiper,新建复刻)
* 数据高内聚:默认内部请求 plugin-uni-halo 公开 banners 接口(getBanners),支持外部 list 覆盖
* 支持:图片轮播、日期角标(useTop,显示当前条目 date 快照)、标题浮层(useTitle)、
* 作者/日期信息浮层(useUser)、底部小图指示器(useDot)
*/
import { computed, onMounted, ref, watch } from 'vue'
import { getBanners } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import type { IBannerPublicItem } from '@/api/types/uni-halo'
export interface IBannerItem {
/** 条目标识(Banner 为 metadata.name;兼容旧数据) */
id ?: string | number
/** Banner 条目 metadata.name(custom 详情页跳转用) */
name ?: string
title ?: string
image ?: string
src ?: string
/** 来源:post=文章快照 / custom=自定义 */
type ?: string
/** 文章 id(source=post 时跳转文章详情) */
postId ?: string
content ?: string
url ?: string
/** 展示日期(ISO 快照) */
date ?: string
authorName ?: string
authorAvatar ?: string
[key : string] : unknown
}
const props = withDefaults(defineProps<{
title ?: string
height ?: string
dotPosition ?: string
/** 日期角标(显示当前条目 date) */
useTop ?: boolean
/** 底部小图指示器 */
useDot ?: boolean
/** 标题浮层 */
useTitle ?: boolean
/** 作者/日期信息浮层 */
useUser ?: boolean
/** 轮播数据列表(可选;不传时组件内部调公开 banners 接口拉取) */
list ?: IBannerItem[]
/** 当前选中的项(指示器坐标位置) */
current ?: number
/** 是否自动轮播 */
autoplay ?: boolean
}>(), {
title: '',
height: '450rpx',
dotPosition: 'bottom',
useTop: true,
useDot: true,
useTitle: true,
useUser: true,
current: 0,
autoplay: false,
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const bannerConfig = computed(() => haloConfigs.value.pageConfig?.homeConfig?.bannerConfig)
/* ---------------- 状态 ---------------- */
const currentIndex = ref(props.current)
/** 是否禁止用户 touch 操作 */
const disableTouch = ref(false)
/* ---------------- 数据(高内聚:内部请求公开接口) ---------------- */
const internalList = ref<IBannerItem[]>([])
/** 展示列表:外部传入(list)优先,否则使用内部拉取数据 */
const displayItems = computed<IBannerItem[]>(() =>
props.list && props.list.length > 0 ? props.list : internalList.value,
)
/** 公开 Banner 条目 → 轮播展示项 */
function mapBanners(items : IBannerPublicItem[]) : IBannerItem[] {
return items.map(item => ({
id: item.name,
name: item.name,
title: item.title || '',
image: checkThumbnailUrl(item.cover),
src: checkThumbnailUrl(item.cover),
type: item.source,
postId: item.postId,
url: item.link,
date: item.date,
authorName: item.authorName,
authorAvatar: item.authorAvatar ? checkAvatarUrl(item.authorAvatar) : '',
}))
}
onMounted(async () => {
// 外部已传数据时不再重复请求
if (props.list && props.list.length > 0) {
return
}
try {
const res = await getBanners()
internalList.value = mapBanners(res.data || [])
}
catch (err) {
console.error('获取轮播图失败', err)
}
})
// 列表变化(外部覆盖/接口返回)后索引越界时归零
watch(displayItems, (val) => {
if (currentIndex.value >= val.length) {
currentIndex.value = 0
}
})
/* ---------------- 计算属性 ---------------- */
const currentItem = computed<IBannerItem>(() =>
displayItems.value[currentIndex.value] || {},
)
/** 日期角标(useTop):当前条目 date 快照转换(年/月/日) */
const dateParts = computed(() => {
const dateStr = currentItem.value.date as string | undefined
if (!dateStr) {
return null
}
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) {
return null
}
const monthArray = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
return {
day: String(d.getDate()).padStart(2, '0'),
month: String(d.getMonth() + 1).padStart(2, '0'),
monthEn: monthArray[d.getMonth()],
year: String(d.getFullYear()),
}
})
const currentTitle = computed(() => currentItem.value.title || props.title || '')
/** 作者日期展示(useUser 用) */
const authorDateText = computed(() => {
const dateStr = currentItem.value.date as string | undefined
if (!dateStr) {
return ''
}
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) {
return ''
}
const pad = (n : number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
})
/* ---------------- 交互 ---------------- */
/** current 改变时会触发 change 事件,event.detail = {current, source} */
function change(e : { detail : { current : number, source : string } }) {
const { current, source } = e.detail
// 只有页面自动切换、手动切换时才轮播,其他不允许
if (source === 'autoplay' || source === 'touch') {
const event = { current }
currentIndex.value = current
}
}
/** 手动点击了指示器[小图模式] */
function swiperIndTap(index : number) {
const event = { current: index }
currentIndex.value = index
}
function handleOnClick(item : IBannerItem) {
// 审核模式下照常展示 Banner,点击分发不拦截(详情页自行处理审核限制)
if (item.type === 'custom') {
// 自定义条目:跳转 Banner 详情页,页面内调公开详情接口展示 content/外链
if (item.name) {
uni.navigateTo({
url: `/pages-blog/banner-detail/banner-detail?name=${item.name}`,
animationType: 'slide-in-right',
})
}
return
}
// 文章来源
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${item.postId}`,
animationType: 'slide-in-right',
})
}
</script>
<template>
<view v-if="displayItems.length > 0" class="relative w-full px-4 pt-12 mb-14 box-border">
<view class="absolute inset-0 blur-[2rpx]">
<image :src="displayItems[currentIndex].src" class="h-full w-full" mode="aspectFill"></image>
</view>
<view class="uh-global-card-glass box-border relative w-full overflow-hidden rounded-xl translate-y-12" :class="[dotPosition]">
<swiper class="w-full" :style="{ height }" :circular="true" :indicator-dots="false"
:autoplay="autoplay" :interval="3000" :duration="1000" :current="currentIndex"
:disable-touch="disableTouch" @change="change">
<swiper-item v-for="(item, index) in displayItems" :key="index">
<image :src="item.image || item.src" class="h-full w-full" mode="aspectFill"
@click.stop="handleOnClick(item)" />
</swiper-item>
</swiper>
<!-- 指示器 [Top 日期角标]:显示当前条目 date(//) -->
<view v-if="useTop && dateParts"
class="box-border absolute inset-x-0 top-0 z-5 flex items-center px-[24rpx] py-[16rpx]">
<text class="text-[40rpx] text-white font-bold text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
{{ dateParts.day }}
</text>
<view class="ml-[12rpx] h-[40rpx] w-[2rpx] bg-white/50" />
<view class="ml-[12rpx] flex flex-col">
<text class="text-[20rpx] text-white text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
{{ dateParts.monthEn }}
</text>
<text class="text-[16rpx] text-white/80">{{ dateParts.year }}</text>
</view>
<text
class="text-overflow-2 ml-[20rpx] block flex-1 text-[24rpx] text-white text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
{{ title }}
</text>
</view>
<!-- 指示器 标题区域 + 作者/日期信息(useUser) -->
<view v-if="useTitle"
class="absolute inset-x-0 bottom-0 z-5 from-black/45 to-transparent bg-gradient-to-t px-[24rpx] pb-[20rpx] pt-[48rpx]">
<view v-if="useUser && (currentItem.authorName || authorDateText)"
class="mb-[8rpx] flex items-center gap-[8rpx]">
<view v-if="currentItem.authorAvatar"
class="h-[36rpx] w-[36rpx] overflow-hidden border-[1rpx] border-white/60 rounded-full">
<image :src="currentItem.authorAvatar" class="h-full w-full" mode="aspectFill" />
</view>
<text class="text-[22rpx] text-white/92 text-shadow-[0_1rpx_4rpx_rgba(0,0,0,0.4)]">
{{ currentItem.authorName }}
</text>
<text v-if="authorDateText"
class="text-[20rpx] text-white/70 text-shadow-[0_1rpx_4rpx_rgba(0,0,0,0.4)]">
{{ authorDateText }}
</text>
</view>
<text v-if="currentTitle"
class="text-overflow-2 block text-[28rpx] text-white font-bold text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
{{ currentTitle }}
</text>
</view>
<!-- 指示器 [底部小图列表] -->
<view v-if="useDot" class="absolute inset-x-0 bottom-3 z-5 flex justify-end px-[24rpx]">
<view class="flex gap-1">
<view v-for="(item, index) in displayItems" :key="index"
class="uh-global-card-glass h-[64rpx] w-[96rpx] overflow-hidden border-[2rpx] rounded-[8rpx]"
:class="currentIndex === index ? 'opacity-100 border-white' : 'opacity-60 border-transparent'"
@click="swiperIndTap(index)">
<image :src="item.image || item.src" class="h-full w-full" mode="aspectFill" />
</view>
</view>
</view>
</view>
</view>
</template>
@@ -1,11 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
/**
* 首页公告滚动条(plugin-uni-halo 通知公告,2026-09-03 客户端新增)
* 展示最新/置顶公告(公开 GET /notices 服务端默认 priority desc + publishTime desc),
* 取前 6 条标题垂直循环轮播:点击当前标题跳公告详情,右侧「更多」跳公告列表页。
* 无公告(或加载失败)时整条不渲染,不占首页空间。
* UIUX 见 .docs/notice-module-client-design.md
*/
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { getNotices } from '@/api/uni-halo' import { getNotices } from '@/api/uni-halo'
import type { INoticeListVo } from '@/api/types/uni-halo' import type { INoticeListVo } from '@/api/types/uni-halo'
@@ -47,14 +40,14 @@ onMounted(() => {
<template> <template>
<view <view
v-if="showList.length > 0" v-if="showList.length > 0"
class="mx-3 mb-2 flex items-center rounded-xl bg-white px-3 py-1.5 shadow-sm" class="uh-global-card-glass box-border mx-3 mt-3 mb-2 flex items-center rounded-xl px-3"
> >
<!-- 左侧公告入口 --> <!-- 左侧公告入口 -->
<view class="flex shrink-0 items-center gap-1 py-2 pr-3" @click="handleGoList"> <view class="flex shrink-0 items-center gap-1 py-2 pr-3" @click="handleGoList">
<text class="text-[28rpx]"> <text class="text-sm">
📢 📢
</text> </text>
<text class="text-[24rpx] font-bold text-[#f83856]"> <text class="text-xs font-bold text-red-400">
公告 公告
</text> </text>
</view> </view>
@@ -76,7 +69,7 @@ onMounted(() => {
class="h-full w-full" class="h-full w-full"
> >
<view <view
class="flex h-full w-full items-center truncate text-[24rpx] text-[#555]" class="flex h-full w-full items-center truncate text-xs text-gray-500"
@click="handleTap(item)" @click="handleTap(item)"
> >
{{ item.title }} {{ item.title }}
@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { getNoticeLatest } from '@/api/uni-halo' import { getNoticeLatest } from '@/api/uni-halo'
import { getCache, setCache } from '@/utils/storage' import { getCache, setCache } from '@/utils/storage'
import { checkImageUrl } from '@/utils/url'
import type { INoticeListVo } from '@/api/types/uni-halo' import type { INoticeListVo } from '@/api/types/uni-halo'
const HIDDEN_KEY_PREFIX = 'notice_latest_hidden' const HIDDEN_KEY_PREFIX = 'notice_latest_hidden'
@@ -27,17 +28,14 @@ function formatDate(value?: string): string {
} }
async function handleCheckLatest() { async function handleCheckLatest() {
if (checking.value) if (checking.value) { return }
return
checking.value = true checking.value = true
try { try {
const res = await getNoticeLatest() const res = await getNoticeLatest()
const latest = res.data const latest = res.data
if (!latest?.name || !latest.title) if (!latest?.name || !latest.title) { return }
return
// 今日已看过则不再打扰 // 今日已看过则不再打扰
if (getCache<string>(todayKey(latest.name))) if (getCache<string>(todayKey(latest.name))) { return }
return
notice.value = latest notice.value = latest
isShow.value = true isShow.value = true
} }
@@ -78,13 +76,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<wd-popup <wd-popup v-model="isShow" position="center" custom-class="rounded-xl" :z-index="9999" @close="handleClose">
v-model="isShow"
position="center"
custom-class="rounded-xl"
z-index="9999"
@close="handleClose"
>
<view v-if="notice" class="box-border w-[80vw] p-6"> <view v-if="notice" class="box-border w-[80vw] p-6">
<!-- 头部:标题 + 关闭 --> <!-- 头部:标题 + 关闭 -->
<view class="flex items-center justify-between"> <view class="flex items-center justify-between">
@@ -95,31 +87,28 @@ onMounted(() => {
<text class="text-md font-bold text-gray-900"> <text class="text-md font-bold text-gray-900">
最新公告 最新公告
</text> </text>
<view <view v-if="notice.typeDisplayName" class="rounded px-1.5 py-0.5 text-xs" :style="{
v-if="notice.typeDisplayName"
class="rounded px-1.5 py-0.5 text-xs"
:style="{
color: notice.typeColor || '#f83856', color: notice.typeColor || '#f83856',
backgroundColor: notice.typeColor ? `${notice.typeColor}1a` : '#fdeef1', backgroundColor: notice.typeColor ? `${notice.typeColor}1a` : '#fdeef1',
}" }">
>
{{ notice.typeDisplayName }} {{ notice.typeDisplayName }}
</view> </view>
</view> </view>
<view class="flex h-8 w-8 items-center justify-center text-gray-500" @click="handleClose"> <view class="flex h-8 w-8 items-center justify-end text-gray-500" @click="handleClose">
<wd-icon name="close" size="16px" /> <wd-icon name="close" size="16px" />
</view> </view>
</view> </view>
<!-- 内容 --> <!-- 内容 -->
<view class="mt-4"> <view class="mt-4">
<view class="text-[32rpx] font-bold leading-snug text-[#222]"> <image v-if="notice.cover" :src="checkImageUrl(notice.cover)" class="w-full h-34 rounded-lg mb-2"></image>
<view class="text-md font-bold leading-snug text-gray-900">
{{ notice.title }} {{ notice.title }}
</view> </view>
<view v-if="notice.summary" class="mt-3 text-[26rpx] leading-relaxed text-gray-500"> <view v-if="notice.summary" class="mt-3 text-sm leading-relaxed text-gray-500">
{{ notice.summary }} {{ notice.summary }}
</view> </view>
<view v-if="notice.publishTime" class="mt-3 text-[22rpx] text-gray-400"> <view v-if="notice.publishTime" class="mt-3 text-xs text-gray-400">
日期{{ formatDate(notice.publishTime) }} 日期{{ formatDate(notice.publishTime) }}
</view> </view>
</view> </view>
@@ -4,7 +4,7 @@
* 当依赖的 Halo 插件未安装/未启用时展示:插件 logo、名称、错误标签、描述、插件地址、复制/反馈按钮 * 当依赖的 Halo 插件未安装/未启用时展示:插件 logo、名称、错误标签、描述、插件地址、复制/反馈按钮
*/ */
import { computed } from 'vue' import { computed } from 'vue'
import { NeedPlugins } from '@/utils/plugin' import { NeedPlugins } from '@/hooks/usePluginAvailable'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** 插件名称(与 NeedPlugins 中的 id 对应) */ /** 插件名称(与 NeedPlugins 中的 id 对应) */
@@ -60,50 +60,29 @@ function copy() {
</script> </script>
<template> <template>
<view <view v-if="pluginInfo"
v-if="pluginInfo"
class="uh-plugin-unavailable mx-auto my-auto box-border flex flex-col gap-6 p-10 text-[28rpx]" class="uh-plugin-unavailable mx-auto my-auto box-border flex flex-col gap-6 p-10 text-[28rpx]"
:class="{ border: useBorder, decoration: useDecoration }" :class="{ border: useBorder, decoration: useDecoration }" :style="[calcCustomStyle]">
:style="[calcCustomStyle]"
>
<!-- 图标 --> <!-- 图标 -->
<image class="plugin-logo box-border h-[120rpx] w-[120rpx] rounded-3xl" :src="pluginInfo.logo" mode="scaleToFill" /> <image class="plugin-logo box-border h-[120rpx] w-[120rpx] rounded-3xl" :src="pluginInfo.logo"
mode="scaleToFill" />
<!-- 名称 --> <!-- 名称 -->
<view class="plugin-name box-border text-[32rpx] text-[#333] font-bold"> <view class="plugin-name box-border text-[32rpx] text-[#333] font-bold">
{{ pluginInfo.name }} {{ pluginInfo.name }}
</view> </view>
<!-- 错误标签 -->
<view class="plugin-error box-border rounded-[36rpx] px-4 py-1.5 text-[24rpx] font-bold" style="background-color: rgb(255 61 49 / 7.5%); color: rgb(255 61 49);">
未安装/启用插件
</view>
<!-- 描述 -->
<view class="plugin-desc box-border w-[60vw] text-center text-[24rpx] text-[#64748b]">
{{ pluginInfo.desc }}
</view>
<!-- 自定义错误提示 --> <!-- 自定义错误提示 -->
<view v-if="errorText" class="plugin-tip box-border border-2 rounded-xl border-dashed px-5 py-2.5 text-[24rpx]" style="border-color: #f2c97d; color: #f0a020;"> <view v-if="errorText" class="plugin-tip box-border border-2 rounded-xl border-dashed px-5 py-2.5 text-[24rpx]"
style="border-color: #f2c97d; color: #f0a020;">
{{ errorText }} {{ errorText }}
</view> </view>
<!-- 插件地址 -->
<view class="plugin-url box-border w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-xl bg-[#f1f5f9] px-6 py-4 text-[24rpx] text-[#666]">
插件地址{{ pluginInfo.url }}
</view>
<!-- 反馈按钮/复制地址 --> <!-- 反馈按钮/复制地址 -->
<view class="plugin-btns box-border w-full"> <view class="plugin-btns box-border w-full">
<!-- #ifndef MP-WEIXIN -->
<wd-button type="primary" block size="medium" @click="copy">
复制地址
</wd-button>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<view class="flex gap-3">
<wd-button type="primary" plain block size="medium" @click="copy">
复制地址
</wd-button>
<wd-button type="warning" plain block size="medium" open-type="contact"> <wd-button type="warning" plain block size="medium" open-type="contact">
提交反馈 提交反馈
</wd-button> </wd-button>
</view>
<!-- #endif --> <!-- #endif -->
</view> </view>
<!-- 刷新按钮 --> <!-- 刷新按钮 -->
@@ -0,0 +1,135 @@
<script lang="ts" setup>
/**
* 插件不可用提示(源自旧项目 components/plugin-unavailable,新建复刻)
* 当依赖的 Halo 插件未安装/未启用时展示:插件 logo、名称、错误标签、描述、插件地址、复制/反馈按钮
*/
import { computed } from 'vue'
import { NeedPlugins } from '@/utils/plugin'
const props = withDefaults(defineProps<{
/** 插件名称(与 NeedPlugins 中的 id 对应) */
pluginId: string
errorText?: string
useDecoration?: boolean
useBorder?: boolean
customStyle?: Record<string, string>
}>(), {
errorText: '',
useDecoration: true,
useBorder: true,
customStyle: () => ({}),
})
const emit = defineEmits<{
(e: 'on-refresh'): void
}>()
/** 插件信息(未在清单中时兜底) */
const pluginInfo = computed(() => {
const info = NeedPlugins.get(props.pluginId)
return info || {
id: props.pluginId,
name: props.pluginId,
desc: '',
logo: '',
url: '',
}
})
const defaultStyle = {
width: '80vw',
borderRadius: '24rpx',
}
const calcCustomStyle = computed(() => ({
...defaultStyle,
...props.customStyle,
}))
function copy() {
if (!pluginInfo.value.url)
return
uni.setClipboardData({
data: pluginInfo.value.url,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: '插件地址已复制' })
},
})
}
</script>
<template>
<view
v-if="pluginInfo"
class="uh-plugin-unavailable mx-auto my-auto box-border flex flex-col gap-6 p-10 text-[28rpx]"
:class="{ border: useBorder, decoration: useDecoration }"
:style="[calcCustomStyle]"
>
<!-- 图标 -->
<image class="plugin-logo box-border h-[120rpx] w-[120rpx] rounded-3xl" :src="pluginInfo.logo" mode="scaleToFill" />
<!-- 名称 -->
<view class="plugin-name box-border text-[32rpx] text-[#333] font-bold">
{{ pluginInfo.name }}
</view>
<!-- 错误标签 -->
<view class="plugin-error box-border rounded-[36rpx] px-4 py-1.5 text-[24rpx] font-bold" style="background-color: rgb(255 61 49 / 7.5%); color: rgb(255 61 49);">
未安装/启用插件
</view>
<!-- 描述 -->
<view class="plugin-desc box-border w-[60vw] text-center text-[24rpx] text-[#64748b]">
{{ pluginInfo.desc }}
</view>
<!-- 自定义错误提示 -->
<view v-if="errorText" class="plugin-tip box-border border-2 rounded-xl border-dashed px-5 py-2.5 text-[24rpx]" style="border-color: #f2c97d; color: #f0a020;">
{{ errorText }}
</view>
<!-- 插件地址 -->
<view class="plugin-url box-border w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-xl bg-[#f1f5f9] px-6 py-4 text-[24rpx] text-[#666]">
插件地址:{{ pluginInfo.url }}
</view>
<!-- 反馈按钮/复制地址 -->
<view class="plugin-btns box-border w-full">
<!-- #ifndef MP-WEIXIN -->
<wd-button type="primary" block size="medium" @click="copy">
复制地址
</wd-button>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view class="flex gap-3">
<wd-button type="primary" plain block size="medium" @click="copy">
复制地址
</wd-button>
<wd-button type="warning" plain block size="medium" open-type="contact">
提交反馈
</wd-button>
</view>
<!-- #endif -->
</view>
<!-- 刷新按钮 -->
<view class="flex justify-center">
<wd-button size="small" plain type="info" @click="emit('on-refresh')">
刷新试试
</wd-button>
</view>
<view class="plugin-copyright text-[20rpx] text-[#999]" style="transform: scale(0.9) translateY(20px);">
提示:请确保 Halo 博客已安装相关插件
</view>
</view>
</template>
<style scoped lang="scss">
.uh-plugin-unavailable {
&.border {
border: 2rpx solid #eee;
}
&.decoration {
background-color: rgb(255 255 255 / 95%);
box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);
backdrop-filter: blur(6rpx);
border-top: 12rpx solid rgb(3 169 244);
}
}
</style>
+4 -3
View File
@@ -6,7 +6,6 @@
* 维护页按 reason 展示默认(未配置维护信息)或配置文案。设计见插件 * 维护页按 reason 展示默认(未配置维护信息)或配置文案。设计见插件
* .docs/maintenance-config-design.md §8。 * .docs/maintenance-config-design.md §8。
*/ */
import { usePluginAvailable } from '@/utils/plugin'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
/** 维护拦截原因:plugin 主插件未激活 / maintenance 维护模式开启 */ /** 维护拦截原因:plugin 主插件未激活 / maintenance 维护模式开启 */
@@ -33,14 +32,16 @@ export const MAINTENANCE_PLUGIN_ID = 'plugin-uni-halo'
*/ */
export function useMaintenanceIntercept() { export function useMaintenanceIntercept() {
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
/** 主插件可用性 hook(checkIntercept 内 await check 后读取 available) */
const { available: pluginAvailable, check: checkPluginAvailable } = usePluginAvailable(MAINTENANCE_PLUGIN_ID)
/** /**
* 检查是否命中拦截(插件可用性 + 维护模式)。 * 检查是否命中拦截(插件可用性 + 维护模式)。
* @param force 是否强制刷新配置(默认 false 走 bootstrap TTL 缓存) * @param force 是否强制刷新配置(默认 false 走 bootstrap TTL 缓存)
*/ */
async function checkIntercept(force = false): Promise<IMaintenanceInterceptResult> { async function checkIntercept(force = false): Promise<IMaintenanceInterceptResult> {
const pluginAvailable = await usePluginAvailable(MAINTENANCE_PLUGIN_ID) await checkPluginAvailable()
if (!pluginAvailable) if (!pluginAvailable.value)
return { intercepted: true, reason: 'plugin' } return { intercepted: true, reason: 'plugin' }
const { ok } = await appConfigStore.bootstrap({ force }) const { ok } = await appConfigStore.bootstrap({ force })
@@ -1,6 +1,21 @@
/** /**
* ( utils/plugin.js,TS ) * hook
* utils/plugin.js(TS ) utils/plugin.ts, hooks
* :
* - ID (NeedPluginIds)(NeedPlugins)
* - (checkNeedPluginAvailable)
* - hook(usePluginAvailable):available + check , import
* (auto-import src/hooks, import)
*
* @example
* const { available, check } = usePluginAvailable('plugin-vote')
* onLoad(async () => {
* await check()
* if (!available.value) return
* handleGetData()
* })
*/ */
import { ref } from 'vue'
import { checkPluginAvailable } from '@/api/halo' import { checkPluginAvailable } from '@/api/halo'
import { checkUrl } from '@/utils/url' import { checkUrl } from '@/utils/url'
@@ -124,10 +139,26 @@ export async function checkNeedPluginAvailable(pluginId: string): Promise<boolea
} }
} }
export function usePluginAvailable(pluginId: string, initial = true) {
/** 插件是否可用(默认 true,避免首帧闪现插件不可用占位;需要先置 false 的页面传 initial=false) */
const available = ref(initial)
/** 是否校验中 */
const checking = ref(false)
/** /**
* ( onLoad 使, uh-plugin-unavailable , script setup export) * ( available)
* @param pluginId id * @returns ( available.value ,便)
*/ */
export async function usePluginAvailable(pluginId: string): Promise<boolean> { async function check(): Promise<boolean> {
return checkNeedPluginAvailable(pluginId) checking.value = true
try {
available.value = await checkNeedPluginAvailable(pluginId)
return available.value
}
finally {
checking.value = false
}
}
return { available, checking, check }
} }
@@ -5,8 +5,10 @@ import { getPostByName, getPostCommentReplyList, postTrackersCounter, submitUpvo
import { createVerificationCode, requestRestrictReadCheck } from '@/api/uni-halo' import { createVerificationCode, requestRestrictReadCheck } from '@/api/uni-halo'
import { formatTime } from '@/utils/formatTime' import { formatTime } from '@/utils/formatTime'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { useFavoritesStore } from '@/store/favorites'
import { useSettingStore } from '@/store/setting' import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url' import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { buildPostFavoriteItem } from '@/utils/favorite'
import { checkPostRestrictRead, copyToClipboard, getRestrictReadTypeName, getShowableContent } from '@/utils/restrictRead' import { checkPostRestrictRead, copyToClipboard, getRestrictReadTypeName, getShowableContent } from '@/utils/restrictRead'
import { getDomainOnly } from '@/utils/urlParams' import { getDomainOnly } from '@/utils/urlParams'
import { markdownConfig } from '@/config/markdown' import { markdownConfig } from '@/config/markdown'
@@ -23,6 +25,7 @@ definePage({
}) })
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
const favoritesStore = useFavoritesStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus() const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
@@ -217,6 +220,22 @@ async function handleDoLikes() {
} }
} }
/* ---------------- 收藏 ---------------- */
/** 当前文章是否已收藏 */
function hasFavorited(): boolean {
const name = result.value?.metadata.name
return !!name && favoritesStore.isFavorite('post', name)
}
/** 切换收藏(收藏/取消),收藏时按当前文章内容生成快照入库 */
function handleTogglePostFavorite() {
const post = result.value
if (!post)
return
const favorited = favoritesStore.toggle(buildPostFavoriteItem(post))
uni.showToast({ icon: 'none', title: favorited ? '收藏成功' : '已取消收藏' })
}
/* ---------------- 受限阅读 ---------------- */ /* ---------------- 受限阅读 ---------------- */
function readMore() { function readMore() {
const annotations = result.value?.metadata?.annotations const annotations = result.value?.metadata?.annotations
@@ -613,10 +632,11 @@ const globalAppSettings = computed(() => settingStore.settings)
</view> </view>
<view <view
class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none" class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none"
@click="handleToComment()" @click="handleTogglePostFavorite"
> >
<wd-icon class-prefix="uhemoji-icon" name="-smile-" size="36rpx" /> <wd-icon class-prefix="uhemoji-icon" name="-smile-" size="36rpx" />
<text class="shrink-0 text-sm text-gray-900 font-semibold">收藏</text> <text class="shrink-0 text-sm text-gray-900 font-semibold"
:style="hasFavorited() ? { color: '#ffb300' } : ''">{{ hasFavorited() ? '已收藏' : '收藏' }}</text>
</view> </view>
</view> </view>
</view> </view>
+70 -94
View File
@@ -1,12 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
/**
* 数据看板页(源自旧项目 pagesA/data-visual,新建复刻)
* 标签统计/分类统计(环形图)、文章发布趋势(热度图)、评论活跃用户(柱状图)、热门文章 Top10(柱状图)
*/
import { ref } from 'vue' import { ref } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app' import { onPullDownRefresh } from '@dcloudio/uni-app'
import { getChartData } from '@/api/uni-halo' import { getChartData } from '@/api/uni-halo'
import { usePluginAvailable } from '@/utils/plugin'
import type { IDataStatistics } from '@/api/uni-halo' import type { IDataStatistics } from '@/api/uni-halo'
definePage({ definePage({
@@ -18,7 +13,7 @@ definePage({
/** 依赖插件(plugin-data-statistics) */ /** 依赖插件(plugin-data-statistics) */
const uniHaloPluginId = 'plugin-data-statistics' const uniHaloPluginId = 'plugin-data-statistics'
const uniHaloPluginAvailable = ref(true) const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading') const loading = ref<'loading' | 'success' | 'error'>('loading')
@@ -139,7 +134,7 @@ async function handleGetData() {
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
async function init() { async function init() {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId) await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) { if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
return return
@@ -159,122 +154,103 @@ init()
</script> </script>
<template> <template>
<view class="app-page box-border min-h-screen w-screen p-6 text-[#353437]" style="background-color: #fafafd;"> <view class="bg-page box-border min-h-screen w-screen p-3">
<uh-plugin-unavailable <uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="uniHaloPluginId"
v-if="!uniHaloPluginAvailable" error-text="阿偶检测到当前插件没有安装或者启用无法使用功能哦请联系管理员" @on-refresh="handleGetData" />
:plugin-id="uniHaloPluginId"
error-text="阿偶检测到当前插件没有安装或者启用无法使用功能哦请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else> <template v-else>
<!-- 加载/错误占位 --> <uh-data-loading v-if="loading !== 'success'" :loading-status="loading" @refresh="handleGetData" />
<view v-if="loading !== 'success'">
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
</view>
<!-- 内容区域 --> <!-- 内容区域 -->
<view v-else class="content flex flex-col gap-6"> <view v-else class="content flex flex-col gap-3">
<!-- 标签统计 --> <!-- 标签统计 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);"> <view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
<view class="card-head flex items-center justify-between" @click="tagChart.isExpand = !tagChart.isExpand"> <uh-section-title>
<view class="card-head-title flex items-baseline gap-2"> 标签统计
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">标签统计</text> <template #right>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">全部标签的文章数量占比</text> <view class="flex items-center gap-x-2">
<text class="text-xs text-gray-500">全部标签的文章数量占比</text>
<wd-icon :name="tagChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
@click="tagChart.isExpand = !tagChart.isExpand" />
</view> </view>
<wd-icon :name="tagChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" /> </template>
</view> </uh-section-title>
<view v-show="tagChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3"> <view v-show="tagChart.isExpand" class="box-border w-full mt-3">
<qiun-data-charts <qiun-data-charts type="ring" :chart-data="tagChart.data"
type="ring" :opts="{ color: chartColors, padding: [5, 5, 5, 5], dataLabel: false, legend: { show: false }, extra: { ring: { ringWidth: 36, offsetAngle: -90, border: true, borderWidth: 1, borderColor: '#FFFFFF' } } }" />
:chart-data="tagChart.data"
:opts="{ color: chartColors, padding: [5, 5, 5, 5], dataLabel: false, legend: { show: false }, extra: { ring: { ringWidth: 36, offsetAngle: -90, border: true, borderWidth: 1, borderColor: '#FFFFFF' } } }"
/>
</view> </view>
</view> </view>
<!-- 分类统计 --> <!-- 分类统计 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);"> <view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
<view class="card-head flex items-center justify-between" @click="categoryChart.isExpand = !categoryChart.isExpand"> <uh-section-title>
<view class="card-head-title flex items-baseline gap-2"> 分类统计
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">分类统计</text> <template #right>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">全部分类的文章数量占比</text> <view class="flex items-center gap-x-2">
<text class="text-xs text-gray-500">全部分类的文章数量占比</text>
<wd-icon :name="categoryChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
@click="categoryChart.isExpand = !categoryChart.isExpand" />
</view> </view>
<wd-icon :name="categoryChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" /> </template>
</view> </uh-section-title>
<view v-show="categoryChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3"> <view v-show="categoryChart.isExpand" class="box-border w-full mt-3">
<qiun-data-charts <qiun-data-charts type="column" :chart-data="categoryChart.data"
type="column" :opts="{ color: chartColors, padding: [20, 15, 10, 15], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 6 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }" />
:chart-data="categoryChart.data"
:opts="{ color: chartColors, padding: [20, 15, 10, 15], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 6 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view> </view>
</view> </view>
<!-- 文章发布趋势 --> <!-- 文章发布趋势 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);"> <view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
<view class="card-head flex items-center justify-between" @click="trandArticleChart.isExpand = !trandArticleChart.isExpand"> <uh-section-title>
<view class="card-head-title flex items-baseline gap-2"> 文章发布趋势
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">文章发布趋势</text> <template #right>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按日期统计文章发布数量</text> <view class="flex items-center gap-x-2">
<text class="text-xs text-gray-500">按日期统计文章发布数量</text>
<wd-icon :name="trandArticleChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
@click="trandArticleChart.isExpand = !trandArticleChart.isExpand" />
</view> </view>
<wd-icon :name="trandArticleChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" /> </template>
</view> </uh-section-title>
<view v-show="trandArticleChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3"> <view v-show="trandArticleChart.isExpand" class="box-border w-full mt-3">
<uh-heatmap :chart-data="trandArticleChart.data" /> <uh-heatmap :chart-data="trandArticleChart.data" />
</view> </view>
</view> </view>
<!-- 评论活跃用户 --> <!-- 评论活跃用户 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);"> <view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
<view class="card-head flex items-center justify-between" @click="userCommentsChart.isExpand = !userCommentsChart.isExpand"> <uh-section-title>
<view class="card-head-title flex items-baseline gap-2"> 评论活跃用户
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">评论活跃用户</text> <template #right>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按评论作者统计评论数量</text> <view class="flex items-center gap-x-2">
<text class="text-xs text-gray-500">按评论作者统计评论数量</text>
<wd-icon :name="userCommentsChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
@click="userCommentsChart.isExpand = !userCommentsChart.isExpand" />
</view> </view>
<wd-icon :name="userCommentsChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" /> </template>
</view> </uh-section-title>
<view v-show="userCommentsChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3"> <view v-show="userCommentsChart.isExpand" class="box-border w-full mt-3">
<qiun-data-charts <qiun-data-charts type="column" :chart-data="userCommentsChart.data"
type="column" :opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }" />
:chart-data="userCommentsChart.data"
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view> </view>
</view> </view>
<!-- 热门文章 Top10 --> <!-- 热门文章 Top10 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);"> <view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
<view class="card-head"> <uh-section-title>
<view class="card-head-title flex items-baseline gap-2"> 热门文章前10
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">热门文章前10</text> <template #right>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按访问量排序的热门文章</text> <view class="flex items-center gap-x-2">
<text class="text-xs text-gray-500">按访问量排序的热门文章</text>
<wd-icon :name="top10ArticlesChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
@click="top10ArticlesChart.isExpand = !top10ArticlesChart.isExpand" />
</view> </view>
</view> </template>
<view class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3"> </uh-section-title>
<qiun-data-charts <view v-show="top10ArticlesChart.isExpand" class="box-border w-full mt-3">
type="column" <qiun-data-charts type="column" :chart-data="top10ArticlesChart.data"
:chart-data="top10ArticlesChart.data" :opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }" />
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
</view> </view>
</template> </template>
<style scoped lang="scss">
.card-head-text {
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 70%;
background-color: #03a9f4;
border-radius: 12rpx;
}
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts" setup>
/**
* 收藏页(纯本地,文章 × 瞬间)
* 功能:双 Tab(仿图库顶部吸顶胶囊 chip)本地收藏列表 + 跳详情 + 删除 + 状态舞台
* 数据:useFavoritesStore(persist),快照自包含,无需网络加载
* 状态:useDataLoadingStatus + uh-data-loading(与 tabbar 页同构);本地同步数据,状态直接推导
*/
import { computed, ref, watchEffect } from 'vue'
import { formatTime } from '@/utils/formatTime'
import { useFavoritesStore } from '@/store/favorites'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { FavoriteKind, IFavoriteItem } from '@/utils/favorite'
definePage({
style: {
navigationBarTitleText: '我的收藏',
backgroundColor: '#f6f3ee',
},
})
const favoritesStore = useFavoritesStore()
/* ---------------- Tab(文章/瞬间) ---------------- */
const activeKind = ref<FavoriteKind>('post')
const tabList = computed(() => [
{ key: 'post' as FavoriteKind, label: '文章', count: favoritesStore.counts.post },
{ key: 'moment' as FavoriteKind, label: '瞬间', count: favoritesStore.counts.moment },
])
const currentItems = computed<IFavoriteItem[]>(() =>
activeKind.value === 'post' ? favoritesStore.postItems : favoritesStore.momentItems,
)
function handleSwitchTab(kind: FavoriteKind) {
activeKind.value = kind
}
/* ---------------- 加载状态机(与 tabbar 页同构) ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
/** 本地数据同步可得:状态由当前 Tab 列表直接推导(空→Empty;loading/error 分支为将来异步数据源预留) */
watchEffect(() => {
updateLoadingStatus(
currentItems.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
)
})
/** uh-data-loading「刷新试试」:空收藏无可刷新来源,引导回首页发现可收藏内容 */
function handleExplore() {
uni.switchTab({ url: '/pages/tabbar/home/home' })
}
/* ---------------- 收藏时间 ---------------- */
function formatCollectTime(time: string): string {
return formatTime({ d: time, f: 'yyyy-MM-dd' })
}
/* ---------------- 交互 ---------------- */
/** 跳转对应类型详情(原内容被删由详情页空态兜底) */
function handleToDetail(item: IFavoriteItem) {
const base = item.kind === 'post'
? '/pages-blog/article-detail/article-detail'
: '/pages-blog/moment-detail/moment-detail'
uni.navigateTo({
url: `${base}?name=${item.id}`,
animationType: 'slide-in-right',
})
}
/** 取消收藏(直接删除 + 轻提示;详情页可随时重新收藏,不弹二次确认) */
function handleRemove(item: IFavoriteItem) {
favoritesStore.remove(item.kind, item.id)
uni.showToast({ icon: 'none', title: '已取消收藏' })
}
/* ---------------- 空态文案 ---------------- */
const emptyText = computed(() => (activeKind.value === 'post' ? '还没有收藏文章' : '还没有收藏瞬间'))
</script>
<template>
<view class="box-border min-h-screen w-screen bg-page pb-10">
<!-- 顶部类型 Tab(与图库页同款:吸顶玻璃胶囊 chip) -->
<wd-sticky>
<scroll-view scroll-x class="w-full whitespace-nowrap">
<view class="flex gap-2 px-3 pb-1 pt-3">
<view
v-for="tab in tabList" :key="tab.key"
class="uh-global-card-glass uh-shadow-xs inline-block border rounded-2xl px-5 py-1.5 text-sm"
:class="tab.key === activeKind ? 'bg-primary font-bold' : 'text-gray-500'"
@click="handleSwitchTab(tab.key)"
>
{{ tab.label }}
<text v-if="tab.count > 0">({{ tab.count }})</text>
</view>
</view>
</scroll-view>
</wd-sticky>
<!-- 状态舞台 + 列表 -->
<view class="flex flex-col gap-3 px-3 pt-2">
<!-- 空态(当前 Tab 无收藏):uh-data-loading 统一渲染,视觉与 tabbar 页一致 -->
<uh-data-loading
v-if="loadingStatus !== 'success'" :loading-status="loadingStatus" min-height="55vh"
:empty-text="emptyText" empty-sub-text="在文章或瞬间的详情页点亮星标,内容会出现在这里"
@refresh="handleExplore"
/>
<!-- 成功态:当前 Tab 列表 -->
<template v-else>
<!-- 文章卡 -->
<template v-if="activeKind === 'post'">
<view
v-for="item in currentItems" :key="item.id"
class="uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white" @click="handleToDetail(item)"
>
<view class="flex gap-3 p-4 pb-3">
<image
v-if="item.cover" :src="item.cover" mode="aspectFill"
class="h-[128rpx] w-[176rpx] shrink-0 rounded-lg"
/>
<view class="min-w-0 flex-1">
<view class="truncate text-sm text-gray-900 font-bold">
{{ item.title || '未命名' }}
</view>
<view v-if="item.content" class="clamp-2 mt-1 text-xs text-gray-500 leading-relaxed">
{{ item.content }}
</view>
</view>
</view>
<!-- 元信息 + 底部操作(详情/删除) -->
<view class="flex items-center justify-between px-4 pb-3">
<view class="min-w-0 flex flex-1 items-center gap-1.5 text-xs text-gray-400">
<image
v-if="item.owner.avatar" :src="item.owner.avatar" mode="aspectFill"
class="h-[36rpx] w-[36rpx] shrink-0 rounded-full"
/>
<text class="max-w-[220rpx] truncate">{{ item.owner.displayName }}</text>
<text class="shrink-0">· 收藏于 {{ formatCollectTime(item.createTime) }}</text>
</view>
<view class="flex shrink-0 items-center gap-1">
<text class="px-2 py-1 text-xs text-primary" @click.stop="handleToDetail(item)">详情</text>
<text class="px-2 py-1 text-xs text-gray-400" @click.stop="handleRemove(item)">删除</text>
</view>
</view>
</view>
</template>
<!-- 瞬间卡 -->
<template v-else>
<view
v-for="item in currentItems" :key="item.id"
class="uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white" @click="handleToDetail(item)"
>
<view class="p-4 pb-3">
<view class="clamp-3 text-sm text-gray-800 leading-relaxed">
{{ item.content || '(暂无内容)' }}
</view>
</view>
<view class="flex items-center justify-between px-4 pb-3">
<view class="min-w-0 flex flex-1 items-center gap-1.5 text-xs text-gray-400">
<image
v-if="item.owner.avatar" :src="item.owner.avatar" mode="aspectFill"
class="h-[36rpx] w-[36rpx] shrink-0 rounded-full"
/>
<text class="max-w-[220rpx] truncate">{{ item.owner.displayName }}</text>
<text class="shrink-0">· 收藏于 {{ formatCollectTime(item.createTime) }}</text>
</view>
<view class="flex shrink-0 items-center gap-1">
<text class="px-2 py-1 text-xs text-primary" @click.stop="handleToDetail(item)">详情</text>
<text class="px-2 py-1 text-xs text-gray-400" @click.stop="handleRemove(item)">删除</text>
</view>
</view>
</view>
</template>
</template>
</view>
</view>
</template>
<style scoped lang="scss">
/* 多行截断(原子类无 line-clamp,scoped 补充) */
.clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.clamp-3 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
</style>
+6 -6
View File
@@ -12,7 +12,7 @@ import { getMiniProgramLinkGroupedList } from '@/api/uni-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 { NeedPluginIds, usePluginAvailable } from '@/utils/plugin' import { NeedPluginIds } from '@/hooks/usePluginAvailable'
import type { ILink, ILinkGroup } from '@/api/types/halo' import type { ILink, ILinkGroup } from '@/api/types/halo'
import type { IMiniProgramLink, IMiniProgramLinkGroupVo } from '@/api/types/uni-halo' import type { IMiniProgramLink, IMiniProgramLinkGroupVo } from '@/api/types/uni-halo'
@@ -32,10 +32,10 @@ const globalAppSettings = computed(() => settingStore.settings)
/* ---------------- 依赖插件 ---------------- */ /* ---------------- 依赖插件 ---------------- */
/** 站点 tab:plugin-links */ /** 站点 tab:plugin-links */
const sitePluginId = NeedPluginIds.PluginLinks const sitePluginId = NeedPluginIds.PluginLinks
const sitePluginAvailable = ref(true) const { available: sitePluginAvailable, check: checkSitePluginAvailable } = usePluginAvailable(sitePluginId)
/** 小程序 tab:plugin-uni-halo */ /** 小程序 tab:plugin-uni-halo */
const miniPluginId = NeedPluginIds.PluginUniHalo const miniPluginId = NeedPluginIds.PluginUniHalo
const miniPluginAvailable = ref(true) const { available: miniPluginAvailable, check: checkMiniPluginAvailable } = usePluginAvailable(miniPluginId)
/* ---------------- tabs ---------------- */ /* ---------------- tabs ---------------- */
const activeTabIndex = ref(0) const activeTabIndex = ref(0)
@@ -268,9 +268,9 @@ function handleSaveMiniProgramCode(link: IMiniProgramLink) {
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
onLoad(async () => { onLoad(async () => {
;[sitePluginAvailable.value, miniPluginAvailable.value] = await Promise.all([ await Promise.all([
usePluginAvailable(sitePluginId), checkSitePluginAvailable(),
usePluginAvailable(miniPluginId), checkMiniPluginAvailable(),
]) ])
if (sitePluginAvailable.value) if (sitePluginAvailable.value)
handleGetLinkGroupData() handleGetLinkGroupData()
+121 -1
View File
@@ -7,9 +7,11 @@
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getMomentByName } from '@/api/halo' import { getMomentByName, submitUpvote } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { useFavoritesStore } from '@/store/favorites'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url' import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import { buildMomentFavoriteItem } from '@/utils/favorite'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime' import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random' import { randomTagColor } from '@/utils/random'
@@ -27,6 +29,7 @@ definePage({
}) })
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
const favoritesStore = useFavoritesStore()
const haloConfigs = computed(() => appConfigStore.configs) const haloConfigs = computed(() => appConfigStore.configs)
const bloggerInfo = computed(() => { const bloggerInfo = computed(() => {
@@ -122,6 +125,83 @@ const calcMastheadMeta = computed(() => {
return time ? formatTimeUtil({ d: time, f: 'yyyy年 · 星期w' }) : '' return time ? formatTimeUtil({ d: time, f: 'yyyy年 · 星期w' }) : ''
}) })
/* ---------------- 收藏 ---------------- */
/** 当前瞬间是否已收藏(悬浮胶囊高亮) */
const momentFavorited = computed(() => {
const name = moment.value?.metadata.name
return !!name && favoritesStore.isFavorite('moment', name)
})
/** 切换收藏(收藏/取消),收藏时按当前详情内容生成快照入库 */
function handleToggleMomentFavorite() {
const card = moment.value
if (!card)
return
const favorited = favoritesStore.toggle(buildMomentFavoriteItem(card))
uni.showToast({ icon: 'none', title: favorited ? '收藏成功' : '已取消收藏' })
}
/* ---------------- 点赞 ---------------- */
const upvotedNames = ref<string[]>([])
function hasUpvoted(): boolean {
return upvotedNames.value.includes(moment.value?.metadata.name || '')
}
async function handleDoLikes() {
const current = moment.value
if (!current)
return
if (hasUpvoted()) {
uni.showToast({ icon: 'none', title: '已经点过赞啦!' })
return
}
try {
await submitUpvote({
group: 'content.halo.run',
plural: 'moments',
name: current.metadata.name,
})
uni.showToast({ icon: 'none', title: '点赞成功!' })
upvotedNames.value.push(current.metadata.name)
if (current.stats) {
current.stats.upvote = (current.stats.upvote || 0) + 1
}
}
catch (err) {
console.error('点赞失败', err)
uni.showToast({ icon: 'none', title: '点赞失败' })
}
}
/* ---------------- 评论 ---------------- */
const commentModal = ref({
show: false,
isComment: false,
postName: '',
title: '',
})
function handleToComment() {
const current = moment.value
if (!current)
return
if (!current.spec.allowComment) {
uni.showToast({ icon: 'none', title: '瞬间已开启禁止评论!' })
return
}
commentModal.value = {
show: true,
isComment: true,
postName: current.metadata.name,
title: '新增评论',
}
}
function handleOnCommentModalClose(data: { refresh: boolean, isSubmit: boolean }) {
commentModal.value.show = false
}
/* ---------------- 视频互斥 ---------------- */ /* ---------------- 视频互斥 ---------------- */
function createVideoContexts(videos: { id?: string }[]) { function createVideoContexts(videos: { id?: string }[]) {
stopAllVideos() stopAllVideos()
@@ -329,9 +409,49 @@ onShareTimeline(() => ({
<view v-else class="h-7" /> <view v-else class="h-7" />
</view> </view>
<!-- 悬浮操作(与文章详情一致:点赞/评论/收藏) -->
<view class="fixed bottom-8 left-1/2 z-10 flex items-center justify-center pb-safe -translate-x-1/2">
<view
class="uh-global-card-glass box-border flex items-center justify-center gap-2 border rounded-full p-1 text-primary"
>
<!-- 点赞 -->
<view
class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none"
:class="{ active: hasUpvoted() }" @click="handleDoLikes"
>
<wd-icon class-prefix="uhemoji-icon" name="-kiss-" size="36rpx" />
<text class="shrink-0 text-sm text-gray-900 font-semibold">点赞</text>
</view>
<!-- 评论 -->
<view
class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none"
@click="handleToComment()"
>
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="36rpx" />
<text class="shrink-0 text-sm text-gray-900 font-semibold">评论</text>
</view>
<!-- 收藏 -->
<view
class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none"
@click="handleToggleMomentFavorite"
>
<wd-icon class-prefix="uhemoji-icon" name="-smile-" size="36rpx" />
<text class="shrink-0 text-sm text-gray-900 font-semibold"
:style="momentFavorited ? { color: '#ffb300' } : ''">{{ momentFavorited ? '已收藏' : '收藏' }}</text>
</view>
</view>
</view>
<!-- 回顶 --> <!-- 回顶 -->
<view class="to-top-btn uh-shadow-xs fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white" @click="handleToTopPage()"> <view class="to-top-btn uh-shadow-xs fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#6b7280" /> <wd-icon name="arrow-up" size="20px" color="#6b7280" />
</view> </view>
<!-- 评论弹窗 -->
<uh-comment-modal
v-if="commentModal.show" :show="commentModal.show" :is-comment="commentModal.isComment"
:title="commentModal.title" :post-name="commentModal.postName" subject-kind="Moment"
@on-close="handleOnCommentModalClose"
/>
</view> </view>
</template> </template>
+2 -3
View File
@@ -7,7 +7,6 @@ import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app' import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getPostListByKeyword } from '@/api/halo' import { getPostListByKeyword } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import { markdownConfig } from '@/config/markdown' import { markdownConfig } from '@/config/markdown'
import { formatTime as formatTimeUtil } from '@/utils/formatTime' import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { debounce } from '@/utils/debounce' import { debounce } from '@/utils/debounce'
@@ -24,7 +23,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
/** 依赖插件(plugin-search-widget) */ /** 依赖插件(plugin-search-widget) */
const uniHaloPluginId = 'plugin-search-widget' const uniHaloPluginId = 'plugin-search-widget'
const uniHaloPluginAvailable = ref(true) const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading') const loading = ref<'loading' | 'success' | 'error'>('loading')
@@ -122,7 +121,7 @@ function handleToTopPage(duration = 500) {
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
onLoad(async () => { onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId) await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) { if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
return return
+2 -3
View File
@@ -7,7 +7,6 @@ import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app' import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getVoteList } from '@/api/uni-halo' import { getVoteList } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import type { IVoteItem } from '@/api/types/uni-halo' import type { IVoteItem } from '@/api/types/uni-halo'
definePage({ definePage({
@@ -22,7 +21,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
/** 依赖插件(plugin-vote) */ /** 依赖插件(plugin-vote) */
const uniHaloPluginId = 'plugin-vote' const uniHaloPluginId = 'plugin-vote'
const uniHaloPluginAvailable = ref(true) const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading') const loading = ref<'loading' | 'success' | 'error'>('loading')
@@ -84,7 +83,7 @@ function handleToTopPage(duration = 500) {
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
onLoad(async () => { onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId) await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) { if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
return return
+3 -2
View File
@@ -3,7 +3,6 @@
import { onLoad, onUnload } from '@dcloudio/uni-app' import { onLoad, onUnload } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { checkUrl } from '@/utils/url' import { checkUrl } from '@/utils/url'
import { usePluginAvailable } from '@/utils/plugin'
import type { IPublicMaintenance } from '@/api/types/uni-halo' import type { IPublicMaintenance } from '@/api/types/uni-halo'
definePage({ definePage({
@@ -23,6 +22,8 @@
const RECOVERY_POLL_INTERVAL = 30 * 1000 const RECOVERY_POLL_INTERVAL = 30 * 1000
const store = useAppConfigStore() const store = useAppConfigStore()
/** 插件可用性(拦截恢复检测用) */
const { check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
const viewState = ref<ViewState>('loading') const viewState = ref<ViewState>('loading')
const maintenance = ref<IPublicMaintenance | null>(null) const maintenance = ref<IPublicMaintenance | null>(null)
@@ -146,7 +147,7 @@
try { try {
await store.bootstrap({ force: true }) await store.bootstrap({ force: true })
if (fromReason.value === 'plugin') { if (fromReason.value === 'plugin') {
const available = await usePluginAvailable(uniHaloPluginId) const available = await checkPluginAvailable()
if (!available) { if (!available) {
const info = store.configs.maintenance const info = store.configs.maintenance
if (info) { if (info) {
+30 -3
View File
@@ -5,13 +5,13 @@
* 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title + 彩色图标块) * 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title + 彩色图标块)
*/ */
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app' import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { getBlogStatistics } from '@/api/halo' import { getBlogStatistics } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { useFavoritesStore } from '@/store/favorites'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url' import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { checkHasAdminLogin } from '@/utils/auth' import { checkHasAdminLogin } from '@/utils/auth'
import { t } from '@/locale' import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import type { IBlogStats } from '@/api/types/halo' import type { IBlogStats } from '@/api/types/halo'
definePage({ definePage({
@@ -23,10 +23,13 @@ definePage({
}) })
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
const favoritesStore = useFavoritesStore()
const haloConfigs = computed(() => appConfigStore.configs) const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled) const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled) const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled) const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
/** 数据看板插件可用性(供导航项显隐判断) */
const { check: checkDataVisualPlugin } = usePluginAvailable('plugin-data-statistics')
/* ---------------- 计算属性 ---------------- */ /* ---------------- 计算属性 ---------------- */
const bloggerInfo = computed(() => { const bloggerInfo = computed(() => {
@@ -116,10 +119,28 @@ function toSolidColor(rgba: string) {
return rgba.replace('0.95)', '1)') return rgba.replace('0.95)', '1)')
} }
/** 收藏导航项右侧文案跟随收藏总数(收藏页返回/切回时刷新) */
function syncFavoritesNavText() {
const nav = navList.value.find(n => n.key === 'favorites')
if (nav) {
nav.rightText = `${favoritesStore.counts.total} 条收藏`
}
}
async function handleGetNavList() { async function handleGetNavList() {
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics') const dataVisualAvailable = await checkDataVisualPlugin()
navList.value = [ navList.value = [
{
key: 'favorites',
title: '我的收藏',
icon: 'star',
bgColor: 'rgba(255, 179, 0, 0.95)',
rightText: '',
path: '/pages-blog/favorites/favorites',
show: true,
group: 'blog',
},
{ {
key: 'data-visual', key: 'data-visual',
title: '数据看板', title: '数据看板',
@@ -217,6 +238,7 @@ async function handleGetNavList() {
group: 'more', group: 'more',
}, },
] ]
syncFavoritesNavText()
} }
/* ---------------- 数据加载 ---------------- */ /* ---------------- 数据加载 ---------------- */
@@ -269,6 +291,11 @@ watch(haloConfigs, () => {
handleGetData() handleGetData()
// /
onShow(() => {
syncFavoritesNavText()
})
onPullDownRefresh(() => { onPullDownRefresh(() => {
handleGetData() handleGetData()
}) })
+13 -19
View File
@@ -9,7 +9,7 @@
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url' import { checkImageUrl } from '@/utils/url'
import { t } from '@/locale' import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin' import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { IPhoto, IPhotoGroup } from '@/api/types/halo' import type { IPhoto, IPhotoGroup } from '@/api/types/halo'
definePage({ definePage({
@@ -27,10 +27,10 @@
/** 依赖插件(plugin-photos) */ /** 依赖插件(plugin-photos) */
const uniHaloPluginId = 'plugin-photos' const uniHaloPluginId = 'plugin-photos'
const uniHaloPluginAvailable = ref(true) const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId, false)
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error' | 'empty'>('loading') const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const category = ref<{ activeIndex : number, list : IPhotoGroup[] }>({ const category = ref<{ activeIndex : number, list : IPhotoGroup[] }>({
activeIndex: 0, activeIndex: 0,
list: [], list: [],
@@ -58,14 +58,12 @@
handleGetData(true) handleGetData(true)
} }
else { else {
loading.value = 'success'
loadMoreText.value = t('common.noMore') loadMoreText.value = t('common.noMore')
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
} }
} }
catch (e) { catch (e) {
console.error(e) console.error(e)
loading.value = 'error'
category.value = { activeIndex: 0, list: [] } category.value = { activeIndex: 0, list: [] }
} }
return return
@@ -82,7 +80,6 @@
} }
catch (e) { catch (e) {
console.error(e) console.error(e)
loading.value = 'error'
category.value = { activeIndex: 0, list: [] } category.value = { activeIndex: 0, list: [] }
} }
} }
@@ -94,7 +91,7 @@
} }
if (!isLoadMore.value) { if (!isLoadMore.value) {
loading.value = 'loading' updateLoadingStatus(DataLoadingStatusEnum.Loading)
} }
loadMoreText.value = '' loadMoreText.value = ''
@@ -110,12 +107,12 @@
? dataList.value.concat(list) ? dataList.value.concat(list)
: list : list
} }
loading.value = dataList.value.length !== 0 ? 'success' : 'empty' updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore') loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
} }
catch (err) { catch (err) {
console.error(err) console.error(err)
loading.value = 'error' updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed') loadMoreText.value = t('common.loadFailed')
} }
finally { finally {
@@ -150,19 +147,17 @@
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
onLoad(async () => { onLoad(async () => {
// //
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId) await checkPluginAvailable()
console.log('uniHaloPluginAvailable',uniHaloPluginAvailable.value)
if (!uniHaloPluginAvailable.value) { if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
return return
} }
})
watch(galleryConfig, (newVal) => {
if (!newVal) //
return
uni.setNavigationBarTitle({ title: newVal.pageTitle || t('page.gallery.title') })
handleGetCategory() handleGetCategory()
}, { deep: true, immediate: true }) })
onPullDownRefresh(() => { onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) { if (!uniHaloPluginAvailable.value) {
@@ -211,9 +206,8 @@
</wd-sticky> </wd-sticky>
<!-- 加载/错误占位 --> <!-- 加载/错误占位 -->
<view v-if="loading !== 'success'" class="box-border p-3"> <uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
<uh-data-loading :loading-status="loading" @refresh="handleGetCategory" /> @refresh="handleGetCategory" />
</view>
<!-- 内容区域 --> <!-- 内容区域 -->
<view v-else class="box-border w-full p-3"> <view v-else class="box-border w-full p-3">
+11 -17
View File
@@ -7,6 +7,7 @@
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 type { IPost } from '@/api/types/halo' import type { IPost } from '@/api/types/halo'
definePage({ definePage({
@@ -28,7 +29,7 @@
const haloConfigs = computed(() => appConfigStore.configs) const haloConfigs = computed(() => appConfigStore.configs)
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading') 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[]>([])
@@ -79,12 +80,12 @@
item.owner.avatar = checkAvatarUrl(item.owner.avatar) item.owner.avatar = checkAvatarUrl(item.owner.avatar)
return item return item
}) })
loading.value = 'success' updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
loadMoreText.value = t('common.noMore') loadMoreText.value = t('common.noMore')
} }
catch (err) { catch (err) {
console.error('获取审核文章失败', err) console.error('获取审核文章失败', err)
loading.value = 'error' updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed') loadMoreText.value = t('common.loadFailed')
} }
finally { finally {
@@ -95,7 +96,7 @@
} }
if (!isLoadMore.value) { if (!isLoadMore.value) {
loading.value = 'loading' updateLoadingStatus(DataLoadingStatusEnum.Loading)
} }
loadMoreText.value = t('common.loading') loadMoreText.value = t('common.loading')
@@ -108,11 +109,11 @@
item.owner.avatar = checkAvatarUrl(item.owner.avatar) item.owner.avatar = checkAvatarUrl(item.owner.avatar)
return item return item
}) })
loading.value = 'success' updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore') loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
} }
catch (err) { catch (err) {
loading.value = 'error' updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed') loadMoreText.value = t('common.loadFailed')
console.error('获取文章失败', err) console.error('获取文章失败', err)
} }
@@ -183,17 +184,10 @@
uni.showToast({ icon: 'none', title: t('common.noMoreData') }) 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-data-loading v-if="loading !== 'success' && articleList.length === 0" :loading-status="loading"
@refresh="handleQuery" />
<!-- 内容区域 -->
<block v-else>
<!-- 轮播 --> <!-- 轮播 -->
<uh-home-banner /> <uh-home-banner />
@@ -217,9 +211,10 @@
</template> </template>
</uh-section-title> </uh-section-title>
<view v-if="articleList.length === 0" class="article-empty py-10"> <!-- 加载/错误占位 -->
<wd-empty description="博主还没有发表任何内容~" /> <uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
</view> min-height="36vh" @refresh="handleQuery" />
<block v-else> <block v-else>
<view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home"> <view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
<uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article" <uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
@@ -232,7 +227,6 @@
<wd-icon name="arrow-up" size="20px" color="#03a9f4" /> <wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view> </view>
</block> </block>
</block>
</view> </view>
<uh-notify-dialog /> <uh-notify-dialog />
</template> </template>
+35 -30
View File
@@ -8,12 +8,14 @@
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app' import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getMomentList } from '@/api/halo' import { getMomentList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { useFavoritesStore } from '@/store/favorites'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url' import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import { buildMomentFavoriteItem } from '@/utils/favorite'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime' import { formatTime } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random' import { randomTagColor } from '@/utils/random'
import { t } from '@/locale' import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin' import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import { markdownConfig } from '@/config/markdown' import { markdownConfig } from '@/config/markdown'
import type { IMoment } from '@/api/types/halo' import type { IMoment } from '@/api/types/halo'
@@ -21,12 +23,11 @@
style: { style: {
navigationBarTitleText: '瞬间', navigationBarTitleText: '瞬间',
enablePullDownRefresh: true, enablePullDownRefresh: true,
// /
backgroundColor: '#f6f3ee',
}, },
}) })
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
const favoritesStore = useFavoritesStore()
const haloConfigs = computed(() => appConfigStore.configs) const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled) const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor) const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
@@ -47,13 +48,13 @@
/** 依赖插件(plugin-moments) */ /** 依赖插件(plugin-moments) */
const uniHaloPluginId = 'plugin-moments' const uniHaloPluginId = 'plugin-moments'
const uniHaloPluginAvailable = ref(true) const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading') const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const queryParams = ref({ size: 10, page: 1 }) const queryParams = ref({ size: 10, page: 1 })
const hasNext = ref(false) const hasNext = ref(false)
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */ /** 列表卡片 */
type MomentCard = IMoment & { type MomentCard = IMoment & {
images ?: { type ?: string, url : string }[] images ?: { type ?: string, url : string }[]
videos ?: { id ?: string, url : string }[] videos ?: { id ?: string, url : string }[]
@@ -66,13 +67,6 @@
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({}) const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null) const currentVideoId = ref<string | null>(null)
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
const calcTagColors = computed(() => {
return dataList.value.map(moment =>
(moment.spec.tags || []).map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f')),
)
})
/** 移除内容中的 tag 链接 */ /** 移除内容中的 tag 链接 */
function removeTagLinksCompletely(htmlString : string) : string { function removeTagLinksCompletely(htmlString : string) : string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
@@ -116,14 +110,14 @@
nextTick(() => { nextTick(() => {
createVideoContexts(tempItems) createVideoContexts(tempItems)
}) })
loading.value = 'success' updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
loadMoreText.value = t('common.noMore') loadMoreText.value = t('common.noMore')
uni.hideLoading() uni.hideLoading()
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
} }
catch (err) { catch (err) {
console.error(err) console.error(err)
loading.value = 'error' updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed') loadMoreText.value = t('common.loadFailed')
} }
return return
@@ -131,13 +125,12 @@
uni.showLoading({ mask: true, title: t('common.loading') }) uni.showLoading({ mask: true, title: t('common.loading') })
if (!isLoadMore.value) { if (!isLoadMore.value) {
loading.value = 'loading' updateLoadingStatus(DataLoadingStatusEnum.Loading)
} }
loadMoreText.value = t('common.loading') loadMoreText.value = t('common.loading')
try { try {
const res = await getMomentList({ ...queryParams.value }) const res = await getMomentList({ ...queryParams.value })
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore') loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext hasNext.value = res.data.hasNext
@@ -148,6 +141,7 @@
dataList.value = isLoadMore.value dataList.value = isLoadMore.value
? dataList.value.concat(tempItems) ? dataList.value.concat(tempItems)
: tempItems : tempItems
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
nextTick(() => { nextTick(() => {
createVideoContexts(tempItems) createVideoContexts(tempItems)
@@ -155,7 +149,7 @@
} }
catch (err) { catch (err) {
console.error(err) console.error(err)
loading.value = 'error' updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed') loadMoreText.value = t('common.loadFailed')
} }
finally { finally {
@@ -216,6 +210,18 @@
}) })
} }
/** 是否已收藏该瞬间(卡片收藏格高亮) */
function isMomentFavorite(moment : IMoment) : boolean {
return favoritesStore.isFavorite('moment', moment.metadata.name)
}
/** 切换收藏(收藏/取消),收藏时按当前卡片内容生成快照入库 */
function handleToggleMomentFavorite(moment : MomentCard) {
if (!moment) { return }
const favorited = favoritesStore.toggle(buildMomentFavoriteItem(moment))
uni.showToast({ icon: 'none', title: favorited ? '收藏成功' : '已取消收藏' })
}
function handleToTopPage(duration = 500) { function handleToTopPage(duration = 500) {
uni.pageScrollTo({ uni.pageScrollTo({
scrollTop: 0, scrollTop: 0,
@@ -228,14 +234,13 @@
/** 格式化瞬间时间 */ /** 格式化瞬间时间 */
function formatMomentTime(time ?: string) : string { function formatMomentTime(time ?: string) : string {
// :yyyyMMdd w return time ? formatTime({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
} }
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
onLoad(async () => { onLoad(async () => {
uni.setNavigationBarTitle({ title: t('page.moments.title') }) uni.setNavigationBarTitle({ title: t('page.moments.title') })
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId) await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) { if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
return return
@@ -256,8 +261,7 @@
}) })
onReachBottom(() => { onReachBottom(() => {
if (!uniHaloPluginAvailable.value) if (!uniHaloPluginAvailable.value) { return }
return
if (calcAuditModeEnabled.value) { if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') }) uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return return
@@ -279,8 +283,8 @@
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员" @on-refresh="handleGetData" /> error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员" @on-refresh="handleGetData" />
<template v-else> <template v-else>
<!-- 加载失败(可重试) --> <!-- 加载失败(可重试) -->
<uh-data-loading v-if="loading !== 'success'" :loading-status="loading" min-height="60vh" <uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
@refresh="handleGetData" /> min-height="60vh" @refresh="handleGetData" />
<view v-else class="flex flex-col gap-3 px-3"> <view v-else class="flex flex-col gap-3 px-3">
<view v-if="dataList.length === 0" <view v-if="dataList.length === 0"
@@ -289,7 +293,7 @@
</view> </view>
<block v-else> <block v-else>
<!-- 瞬间卡片(社交信息流:着色昵称 + 朋友圈式不缩进正文 + 媒体九宫格 + 内嵌互动脚注) --> <!-- 瞬间卡片-->
<view v-for="moment in dataList" :key="moment.metadata.name" <view v-for="moment in dataList" :key="moment.metadata.name"
class="moment-card uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white"> class="moment-card uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white">
<!-- 作者 --> <!-- 作者 -->
@@ -372,9 +376,10 @@
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" /> <wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
<text class="text-sm text-gray-600">评论 {{ moment.stats.totalComment || 0 }}</text> <text class="text-sm text-gray-600">评论 {{ moment.stats.totalComment || 0 }}</text>
</view> </view>
<view class="flex items-center gap-x-1"> <view class="flex items-center gap-x-1" @click.stop="handleToggleMomentFavorite(moment)">
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" /> <wd-icon class-prefix="uhemoji-icon" name="-smile-" size="32rpx" />
<text class="text-sm text-gray-600">收藏</text> <text class="text-sm text-gray-600"
:style="isMomentFavorite(moment) ? { color: '#ffb300' } : ''">{{ isMomentFavorite(moment) ? '已收藏' : '收藏' }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -1,423 +0,0 @@
<script lang="ts" setup>
/**
* 瞬间页(源自旧项目 pages/tabbar/moments/moments.vue,新建复刻)
* 功能:瞬间卡片列表(头像/内容/图片/音频/视频/标签) + 分页加载
* 设计:固定壁纸光斑层为卡片毛玻璃取色(苹果风玻璃拟态)
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getMomentList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import { markdownConfig } from '@/config/markdown'
import type { IMoment } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '瞬间',
enablePullDownRefresh: true,
// 下拉/回弹露出的窗口底色对齐页面底色,壁纸光斑由页面内提供
backgroundColor: '#f6f3ee',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
const siteName = computed(() => {
const appInfo = haloConfigs.value.appConfig?.appInfo as { name?: string } | undefined
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
})
/** 依赖插件(plugin-moments) */
const uniHaloPluginId = 'plugin-moments'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 1 })
const hasNext = ref(false)
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
type MomentCard = IMoment & {
images?: { type?: string, url: string }[]
videos?: { id?: string, url: string }[]
audios?: { type?: string, url: string }[]
spec: IMoment['spec'] & { newHtml?: string }
}
const dataList = ref<MomentCard[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null)
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
const calcTagColors = computed(() => {
return dataList.value.map(moment =>
(moment.spec.tags || []).map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f')),
)
})
/** 移除内容中的 tag 链接 */
function removeTagLinksCompletely(htmlString: string): string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
return htmlString.replace(regex, '')
}
/** 瞬间项映射(spec.content.medium 拆分为 images/videos/audios + 内容 tag 清理 + 作者兜底) */
function mapMomentItem(item: IMoment): MomentCard {
const medium = (item.spec.content?.medium || [])
.map(x => ({ ...x, url: x.url || '' }))
const owner = item.owner
return {
...item,
// 无顶层 owner(如个别历史接口)时兜底为博主信息
owner: owner?.displayName
? owner
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
spec: {
...item.spec,
newHtml: removeTagLinksCompletely(item.spec.content?.html || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
audios: medium.filter(x => x.type === 'AUDIO'),
}
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
// 审核模式:真实瞬间按 audit-data moments 过滤(数组顺序即展示顺序)
const auditMomentNames = appConfigStore.auditData.spec?.moments || []
try {
const res = await getMomentList({ page: 1, size: 99999 })
const filtered = res.data.items
.filter(x => x.spec.visible === 'PUBLIC' && auditMomentNames.includes(x.metadata.name))
const orderMap = new Map(auditMomentNames.map((name, index) => [name, index]))
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
const tempItems = filtered.map(mapMomentItem)
dataList.value = tempItems
nextTick(() => {
createVideoContexts(tempItems)
})
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
return
}
uni.showLoading({ mask: true, title: t('common.loading') })
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
try {
const res = await getMomentList({ ...queryParams.value })
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext
const tempItems = res.data.items
.filter(x => x.spec.visible === 'PUBLIC')
.map(mapMomentItem)
dataList.value = isLoadMore.value
? dataList.value.concat(tempItems)
: tempItems
nextTick(() => {
createVideoContexts(tempItems)
})
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
/* ---------------- 视频互斥 ---------------- */
function createVideoContexts(list: { videos?: { id?: string }[] }[]) {
stopAllVideos()
list.map(item => item.videos || []).flat().forEach((item) => {
if (item.id) {
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
}
})
}
function stopAllVideos(excludesVideoId: string | null = null) {
Object.keys(videoContexts.value).forEach((videoId) => {
if (!excludesVideoId || excludesVideoId !== videoId) {
videoContexts.value[videoId]?.pause()
}
})
}
function onVideoPlay(videoId: string) {
currentVideoId.value = videoId
stopAllVideos(videoId)
}
function onVideoPause(videoId: string) {
if (currentVideoId.value === videoId) {
currentVideoId.value = null
}
}
function onVideoEnded() {
currentVideoId.value = null
}
/* ---------------- 交互 ---------------- */
function handlePreview(index: number, list: { url: string }[]) {
uni.previewImage({
current: index,
urls: list.map(item => item.url),
})
}
function handleToMomentDetail(moment: IMoment) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/** 格式化瞬间时间 */
function formatMomentTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uni.setNavigationBarTitle({ title: t('page.moments.title') })
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
videoContexts.value = {}
currentVideoId.value = null
handleGetData()
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
</script>
<template>
<view class="relative box-border min-h-screen w-screen flex flex-col bg-page py-6">
<!-- 壁纸(柔和光斑为卡片毛玻璃取色,固定不随滚动) -->
<view class="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<view class="absolute h-[420rpx] w-[420rpx] rounded-full bg-[rgba(103,164,242,0.15)] -left-[120rpx] -top-[60rpx]" />
<view class="absolute top-[260rpx] h-[360rpx] w-[360rpx] rounded-full bg-[rgba(244,143,177,0.14)] -right-[130rpx]" />
<view class="absolute top-[700rpx] h-[400rpx] w-[400rpx] rounded-full bg-[rgba(179,157,219,0.13)] -left-[150rpx]" />
<view class="absolute top-[1120rpx] h-[380rpx] w-[380rpx] rounded-full bg-[rgba(77,208,235,0.12)] -right-[110rpx]" />
<view class="absolute left-[180rpx] top-[1560rpx] h-[420rpx] w-[420rpx] rounded-full bg-[rgba(185,228,36,0.14)]" />
</view>
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else>
<!-- 加载中 -->
<view v-if="loading === 'loading'" class="loading-wrap p-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<!-- 加载失败(可重试) -->
<uh-data-loading
v-else-if="loading === 'error'"
:loading-status="loading"
min-height="60vh"
error-text="瞬间加载失败,请点击重试"
@refresh="handleGetData"
/>
<view v-else class="relative z-1 flex flex-col gap-y-4 p-4">
<view v-if="dataList.length === 0" class="min-h-[70vh] w-full flex items-center justify-center content-empty">
<wd-empty :description="t('common.empty')" />
</view>
<block v-else>
<!-- 瞬间卡片(玻璃) -->
<view v-for="(moment, mIndex) in dataList" :key="moment.metadata.name" class="uh-global-card-glass flex flex-col overflow-hidden rounded-[32rpx]">
<view class="head flex items-center p-3 pb-0">
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 border-2 border-white/80 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="nickname ml-3">
<view class="nickname-text text-[30rpx] text-gray-900 font-bold">
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1 text-[24rpx] text-gray-400">
{{ formatMomentTime(moment.spec.releaseTime) }}
</view>
</view>
</view>
<view class="moment-content px-3 py-2" @click.stop="handleToMomentDetail(moment)">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</view>
<!-- 图片 -->
<view v-if="moment.images && moment.images.length !== 0" class="images flex flex-wrap items-start px-3 pb-4">
<view
v-for="(image, mediumIndex) in moment.images"
:key="mediumIndex"
class="image-item box-border p-1"
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')"
>
<image
mode="aspectFill"
class="image-src h-full w-full rounded-lg"
:src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])"
/>
</view>
</view>
<!-- 音频 -->
<view v-if="moment.audios && moment.audios.length !== 0" class="audio-list mb-3 flex flex-col gap-3 px-3">
<uh-audio-player
v-for="audio in moment.audios"
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${siteName}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
<!-- 视频 -->
<view v-if="moment.videos && moment.videos.length !== 0" class="video-list mb-3 flex flex-col gap-3 px-3">
<video
v-for="(video, index) in moment.videos"
:id="`video_${video.id}`"
:key="index"
class="video-src h-[400rpx] w-full rounded-xl"
:src="video.url"
:show-mute-btn="true"
:controls="true"
:show-center-play-btn="true"
:enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')"
@pause="onVideoPause(video.id || '')"
@ended="onVideoEnded"
/>
</view>
<!-- 标签 -->
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-2 px-3 pb-4 pt-1">
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="rounded-full bg-black/5 px-3 py-1 text-[22rpx] font-bold" :style="{ color: calcTagColors[mIndex]?.[tagIndex] }">
# {{ tag }}
</view>
</view>
<!-- 互动数据(点赞/评论) -->
<view class="flex items-center justify-end gap-2 px-4 pb-4">
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
<wd-icon name="heart" size="12px" color="#f08585" />
<text>{{ moment.stats.upvote || 0 }}</text>
</view>
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
<wd-icon name="message" size="12px" color="#7f8ea3" />
<text>{{ moment.stats.totalComment || 0 }}</text>
</view>
</view>
</view>
<view class="to-top-btn uh-global-card-glass fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
</view>
<view class="load-text pb-5 text-center text-[24rpx] text-gray-400">
{{ loadMoreText }}
</view>
</block>
</view>
</template>
</view>
</template>
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { useFavoritesStore } from './favorites'
import type { FavoriteKind, IFavoriteItem } from '@/utils/favorite'
/** 构造合法收藏项(createTime 默认递增,保证字典序即可乱序测试) */
let seq = 0
function makeItem(kind: FavoriteKind, id: string, createTime?: string): IFavoriteItem {
seq += 1
return {
kind,
id,
title: kind === 'post' ? `标题${id}` : undefined,
cover: kind === 'post' ? 'https://example.com/cover.png' : undefined,
content: `内容${id}-${seq}`,
createTime: createTime || new Date(2026, 8, seq).toISOString(),
owner: { id: 'owner-1', displayName: '博主', avatar: 'https://example.com/a.png' },
}
}
describe('useFavoritesStore', () => {
it('add:文章与瞬间分类存储并统计数量', () => {
const store = useFavoritesStore()
store.add(makeItem('post', 'p-1'))
store.add(makeItem('moment', 'm-1'))
expect(store.postItems.map(i => i.id)).toEqual(['p-1'])
expect(store.momentItems.map(i => i.id)).toEqual(['m-1'])
expect(store.counts).toEqual({ post: 1, moment: 1, total: 2 })
})
it('postItems/momentItems:按收藏时间倒序排列', () => {
const store = useFavoritesStore()
store.add(makeItem('post', 'p-old', '2026-08-01T00:00:00.000Z'))
store.add(makeItem('post', 'p-new', '2026-09-01T00:00:00.000Z'))
store.add(makeItem('moment', 'm-old', '2026-07-01T00:00:00.000Z'))
store.add(makeItem('moment', 'm-new', '2026-10-01T00:00:00.000Z'))
expect(store.postItems.map(i => i.id)).toEqual(['p-new', 'p-old'])
expect(store.momentItems.map(i => i.id)).toEqual(['m-new', 'm-old'])
})
it('add:同一 kind+id 重复收藏幂等忽略', () => {
const store = useFavoritesStore()
const item = makeItem('post', 'p-1')
expect(store.add(item)).toBe(true)
expect(store.add(item)).toBe(false)
expect(store.postItems).toHaveLength(1)
})
it('add:文章与瞬间允许相同 id(不同 kind 互不冲突)', () => {
const store = useFavoritesStore()
store.add(makeItem('post', 'same'))
store.add(makeItem('moment', 'same'))
expect(store.counts.total).toBe(2)
expect(store.isFavorite('post', 'same')).toBe(true)
expect(store.isFavorite('moment', 'same')).toBe(true)
expect(store.remove('post', 'same')).toBe(true)
expect(store.isFavorite('moment', 'same')).toBe(true)
expect(store.counts.moment).toBe(1)
})
it('isFavorite:未收藏返回 false,收藏后返回 true', () => {
const store = useFavoritesStore()
expect(store.isFavorite('moment', 'm-1')).toBe(false)
store.add(makeItem('moment', 'm-1'))
expect(store.isFavorite('moment', 'm-1')).toBe(true)
})
it('toggle:未收藏→收藏返回 true;已收藏→取消返回 false', () => {
const store = useFavoritesStore()
expect(store.toggle(makeItem('moment', 'm-1'))).toBe(true)
expect(store.isFavorite('moment', 'm-1')).toBe(true)
expect(store.toggle(makeItem('moment', 'm-1'))).toBe(false)
expect(store.isFavorite('moment', 'm-1')).toBe(false)
})
it('remove:删除存在的项返回 true,重复删除返回 false', () => {
const store = useFavoritesStore()
store.add(makeItem('post', 'p-1'))
expect(store.remove('post', 'p-1')).toBe(true)
expect(store.postItems).toHaveLength(0)
expect(store.remove('post', 'p-1')).toBe(false)
})
it('add:结构非法的收藏项被拒绝', () => {
const store = useFavoritesStore()
expect(store.add({} as IFavoriteItem)).toBe(false)
expect(store.add({ kind: 'post' } as IFavoriteItem)).toBe(false)
expect(store.list).toHaveLength(0)
})
it('脏数据:持久化读回的非法项不进入列表与统计', () => {
const store = useFavoritesStore()
store.add(makeItem('post', 'p-ok', '2026-09-01T00:00:00.000Z'))
// 模拟 storage 中残留的结构非法项(绕过 add 直接写入)
store.list.push({
kind: 'post',
id: '',
content: 'bad',
createTime: '2026-09-02T00:00:00.000Z',
owner: { displayName: '' },
} as unknown as IFavoriteItem)
store.list.push({ not: 'an-item' } as unknown as IFavoriteItem)
expect(store.postItems.map(i => i.id)).toEqual(['p-ok'])
expect(store.counts).toEqual({ post: 1, moment: 0, total: 1 })
expect(store.isFavorite('post', 'p-ok')).toBe(true)
})
})
+83
View File
@@ -0,0 +1,83 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { favoriteKey, isValidFavoriteItem } from '@/utils/favorite'
import type { FavoriteKind, IFavoriteItem } from '@/utils/favorite'
/**
* store(/,persist )
* - 纯本地:收藏数据自包含快照,
* - kind + ':' + id;, isValidFavoriteItem
*/
export const useFavoritesStore = defineStore(
'favorites',
() => {
/** 收藏列表(存储顺序为插入顺序,展示按 createTime 倒序) */
const list = ref<IFavoriteItem[]>([])
/** 过滤脏数据后的合法列表 */
const validList = computed(() => list.value.filter(isValidFavoriteItem))
/** 按收藏时间倒序 */
function sortByCreateTimeDesc(items: IFavoriteItem[]): IFavoriteItem[] {
return [...items].sort((a, b) => b.createTime.localeCompare(a.createTime))
}
/** 文章收藏(倒序) */
const postItems = computed(() => sortByCreateTimeDesc(validList.value.filter(item => item.kind === 'post')))
/** 瞬间收藏(倒序) */
const momentItems = computed(() => sortByCreateTimeDesc(validList.value.filter(item => item.kind === 'moment')))
/** 各类型数量 */
const counts = computed(() => ({
post: postItems.value.length,
moment: momentItems.value.length,
total: validList.value.length,
}))
/** 是否已收藏 */
function isFavorite(kind: FavoriteKind, id: string): boolean {
const key = favoriteKey(kind, id)
return validList.value.some(item => favoriteKey(item.kind, item.id) === key)
}
/** 添加收藏(已存在则幂等忽略) */
function add(item: IFavoriteItem): boolean {
if (!isValidFavoriteItem(item) || isFavorite(item.kind, item.id))
return false
list.value.push(item)
return true
}
/** 取消收藏,返回是否删除成功 */
function remove(kind: FavoriteKind, id: string): boolean {
const key = favoriteKey(kind, id)
const index = list.value.findIndex(item => favoriteKey(item.kind, item.id) === key)
if (index === -1)
return false
list.value.splice(index, 1)
return true
}
/** 切换收藏,返回操作后是否处于收藏态 */
function toggle(item: IFavoriteItem): boolean {
if (isFavorite(item.kind, item.id))
remove(item.kind, item.id)
else
add(item)
return isFavorite(item.kind, item.id)
}
return {
list,
postItems,
momentItems,
counts,
isFavorite,
add,
remove,
toggle,
}
},
{
persist: true,
},
)
+1
View File
@@ -16,6 +16,7 @@ setActivePinia(store)
export default store export default store
export * from './appConfig' export * from './appConfig'
export * from './favorites'
export * from './halo' export * from './halo'
export * from './setting' export * from './setting'
// 模块统一导出 // 模块统一导出
+104
View File
@@ -0,0 +1,104 @@
/**
* 收藏快照:把文章(IPost)/(IMoment) IFavoriteItem
* ,(), store
*/
import type { IMoment, IPost } from '@/api/types/halo'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { extractPlainExcerpt } from '@/utils/text'
/** 收藏内容类型 */
export type FavoriteKind = 'post' | 'moment'
/** 收藏项作者快照 */
export interface IFavoriteOwner {
/** post: owner.metadata.name;moment: owner.name */
id?: string
displayName: string
avatar: string
}
/** 统一收藏项(文章/瞬间,纯本地) */
export interface IFavoriteItem {
kind: FavoriteKind
/** 详情 id(metadata.name) */
id: string
/** 封面(仅文章) */
cover?: string
/** 标题(仅文章) */
title?: string
/** 纯文本摘要:文章 excerpt/正文抽 120 字;瞬间正文抽 200 字 */
content: string
/** 收藏时刻(ISO 字符串) */
createTime: string
owner: IFavoriteOwner
}
/** 文章正文/摘要截断字数 */
const POST_EXCERPT_MAX = 120
/** 瞬间正文截断字数 */
const MOMENT_EXCERPT_MAX = 200
/** 取文章摘要文本(excerpt 优先,兜底从正文抽取) */
function getPostExcerptText(post: IPost): string {
return extractPlainExcerpt(post.spec.excerpt || post.content?.content || post.content?.raw, POST_EXCERPT_MAX)
}
/** 文章 → 收藏快照 */
export function buildPostFavoriteItem(post: IPost, now: Date = new Date()): IFavoriteItem {
const owner = post.owner
const cover = post.spec.cover ? checkImageUrl(post.spec.cover) : undefined
return {
kind: 'post',
id: post.metadata.name,
cover,
title: post.spec.title,
content: getPostExcerptText(post),
createTime: now.toISOString(),
owner: {
id: owner.metadata?.name,
displayName: owner.displayName || '',
avatar: checkAvatarUrl(owner.avatar),
},
}
}
/** 瞬间 → 收藏快照(无封面/标题) */
export function buildMomentFavoriteItem(moment: IMoment, now: Date = new Date()): IFavoriteItem {
const owner = moment.owner
// moment.owner 含 index signature,metadata 需显式收窄
const ownerMeta = owner?.metadata as { name?: string } | undefined
return {
kind: 'moment',
id: moment.metadata.name,
content: extractPlainExcerpt(moment.spec.content?.html || moment.spec.content?.raw, MOMENT_EXCERPT_MAX),
createTime: now.toISOString(),
owner: {
id: owner?.name || ownerMeta?.name,
displayName: owner?.displayName || '',
avatar: checkAvatarUrl(owner?.avatar),
},
}
}
/** 收藏唯一键(kind 与 id 拼接,两域 metadata.name 可能撞名) */
export function favoriteKey(kind: FavoriteKind, id: string): string {
return `${kind}:${id}`
}
/**
* ,/
* ,
*/
export function isValidFavoriteItem(item: unknown): item is IFavoriteItem {
if (!item || typeof item !== 'object')
return false
const target = item as Partial<IFavoriteItem>
const kindOk = target.kind === 'post' || target.kind === 'moment'
const idOk = typeof target.id === 'string' && target.id !== ''
const contentOk = typeof target.content === 'string'
const createTimeOk = typeof target.createTime === 'string'
const ownerOk = !!target.owner
&& typeof target.owner === 'object'
&& typeof (target.owner as IFavoriteOwner).displayName === 'string'
return kindOk && idOk && contentOk && createTimeOk && ownerOk
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 文本工具:HTML +
* ( DOM),, DOMParser
*/
/** HTML 实体解码(覆盖常见实体即可) */
const HTML_ENTITY_MAP: Record<string, string> = {
'&nbsp;': ' ',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': '\'',
'&amp;': '&',
}
/** 行首 markdown 标记(# 标题 / * - 列表 / 数字序号 / 引用 > / 代码块 ``` 等) */
const MD_PREFIX_REG = /^\s{0,3}(#{1,6}[ \t]|>|[+*-][ \t]|\d+[.、)][ \t]|```|~~~|!?\[)/gm
/**
* HTML/Markdown
* @param source ()
*/
export function htmlToPlainText(source?: string): string {
if (!source)
return ''
let text = source
// 剥离脚本/样式块
text = text.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
// 块级/换行标签替换为空格,其余标签整体剥离
text = text
.replace(/<\/(p|div|br|li|h[1-6]|blockquote|pre|tr|section|article)>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
// markdown 行首符号清理
text = text.replace(MD_PREFIX_REG, '')
// 实体解码
text = text.replace(/&[a-z]+;|&#\d+;/gi, match => HTML_ENTITY_MAP[match.toLowerCase()] ?? ' ')
// 压缩空白(含换行)
return text.replace(/\s+/g, ' ').trim()
}
/** 截断文本,超长追加省略号 */
export function truncateText(text: string, max: number, ellipsis = '…'): string {
if (!text || text.length <= max)
return text
return `${text.slice(0, max).trimEnd()}${ellipsis}`
}
/** 从 HTML/Markdown 源提取纯文本摘要(去标签 + 截断) */
export function extractPlainExcerpt(source?: string, max = 120): string {
return truncateText(htmlToPlainText(source), max)
}