diff --git a/.agents/skills/uni-halo/SKILL.md b/.agents/skills/uni-halo/SKILL.md
index 6cf6b0b..0955744 100644
--- a/.agents/skills/uni-halo/SKILL.md
+++ b/.agents/skills/uni-halo/SKILL.md
@@ -16,7 +16,7 @@ license: Complete terms in LICENSE.txt
- **定义或修改 API**(约定使用 alova,格式、`meta` 传参、类型文件位置)
- **定义或修改类型**(`src/api/types/` 下的接口与请求/响应类型)
- **使用通用配置**(`src/config/` 的 appConfig / appSettings / haloGlobal / markdown)
-- **页面数据请求**(`useDataLoading` / `useRequest` hooks + `uh-data-loading` 组件)
+- **页面数据请求**(`useDataLoadingStatus` + `updateLoadingStatus` 管理四态 + `uh-data-loading` 组件)
- **处理多平台差异**(H5 / 微信小程序 / APP,条件编译)
## 三条铁律(先记住)
@@ -295,81 +295,128 @@ onLoad(() => {
- 页面根节点用 `app-page` 类 + 主题底色:``
- 页面标题 `navigationBarTitleText` 写中文;下拉刷新 `enablePullDownRefresh: true`
-### 5.4 页面数据请求(统一 hooks + uh-data-loading 组件)
+### 5.4 页面数据请求(统一 updateLoadingStatus + uh-data-loading 组件)
-**数据加载四态**:`loading / error / empty / success`,由 `useDataLoading` hook 接管,
-页面只提供请求函数,**取代「手工 ref + try/catch 逐个搬运状态」的写法**。
+**数据加载四态**:`loading / error / empty / success`,统一用
+`useDataLoadingStatus`(`src/hooks/useDataLoadingStatus.ts`)的
+`updateLoadingStatus(DataLoadingStatusEnum.Xxx)` 管理,**取代「手工 ref + try/catch 逐个搬运状态」的写法**。
+首页、图库、瞬间、分类等 tabbar 页面均为此写法(参考 `src/pages/tabbar/category/category.vue`),
+**后续所有页面请求状态一律照此管理**。
```vue
-
+
-
+
-
- {{ moment?.spec.releaseTime }}
-
+
+
+ {{ item.spec.displayName }}
+
+
+ {{ loadMoreText }}
+
+
```
**要点**:
-- `useDataLoading(fetcher, { isEmpty, onSuccess, onError })` 返回 `{ data, status, run }`
- - `isEmpty` 自定义判空(默认:数组看长度、对象看键数、空值视为空)
- - `run` 已捕获异常,失败置 `status='error'`,不会向外抛出
-- 模板里 `v-if="status !== 'success'"` 显示 ``,否则渲染数据
+- `useDataLoadingStatus()` 返回 `{ loadingStatus, updateLoadingStatus }`;`DataLoadingStatusEnum` 四态:
+ `Loading / Error / Empty / Success`
+- 请求开始置 `DataLoadingStatusEnum.Loading`;成功按数据是否为空置 `Success` / `Empty`;失败置 `Error`
+- 模板里 `v-if="loadingStatus !== DataLoadingStatusEnum.Success"` 显示 ``,否则渲染数据
- `` 常用 props:`loading-status`(必传)、`min-height`、`loading-text`、
`error-text`、`empty-text`(留空显示默认文案);`@refresh` 绑重试函数
-- **旧页面**用的 `useDataLoadingStatus`(`DataLoadingStatusEnum`)已标记 `@deprecated`,
- 新代码一律用 `useDataLoading`
+- 首页特例:入口拦截 + 文章列表空时用 `v-if="loadingStatus !== Success && articleList.length === 0"`,
+ 避免轮播/公告区被占位组件顶掉
+- 详情页等单数据场景(如 `moment-detail`)可用 `useDataLoading` 状态机(返回 `{ data, status, run }`),
+ 列表页/四态展示优先 `updateLoadingStatus` 写法
- 简单场景也可用 `useRequest(fn, { immediate })`:返回 `{ loading, error, data, run }`
### 5.5 列表页(分页加载)
@@ -640,7 +687,7 @@ loadMoreText.value = t('common.loadMore')
- 新 hooks 放 `src/hooks/`,auto-import(`unplugin-auto-import` 已配 `dirs: ['src/hooks']`),页面**免 import 直接调用**
- 命名 `useXxx`;有配套单测的写 `xxx.test.ts`(如 `useDataLoading.test.ts`、`useRequest.test.ts`)
-- 参考既有实现风格:`useDataLoading`(状态机四态)、`useScroll`、`useUpload`(平台条件编译处理)
+- 参考既有实现风格:`useDataLoadingStatus`(页面四态管理,见 §5.4)、`useDataLoading`(详情页单数据状态机)、`useScroll`、`useUpload`(平台条件编译处理)
### 8.6 Git 提交与合入
diff --git a/src/components/uh-comment-modal/uh-comment-modal.vue b/src/components/uh-comment-modal/uh-comment-modal.vue
index 6fdbfe3..d28c4cd 100644
--- a/src/components/uh-comment-modal/uh-comment-modal.vue
+++ b/src/components/uh-comment-modal/uh-comment-modal.vue
@@ -10,9 +10,12 @@
isComment ?: boolean
title ?: string
postName : string
+ /** 评论目标 kind(文章 Post / 瞬间 Moment) */
+ subjectKind ?: string
}>(), {
isComment: false,
title: '',
+ subjectKind: 'Post',
})
const emit = defineEmits<{
@@ -189,7 +192,7 @@
},
subjectRef: {
group: 'content.halo.run',
- kind: 'Post',
+ kind: props.subjectKind,
name: form.value.postName,
version: 'v1alpha1',
},
diff --git a/src/components/uh-data-loading/uh-data-loading.vue b/src/components/uh-data-loading/uh-data-loading.vue
index 2ceb7b5..a4baa37 100644
--- a/src/components/uh-data-loading/uh-data-loading.vue
+++ b/src/components/uh-data-loading/uh-data-loading.vue
@@ -18,8 +18,8 @@ interface IProps {
const props = withDefaults(defineProps(), {
loadingStatus: 'loading',
- minHeight: '60vh',
- loadingText: '稍等,正在加载中哦...',
+ minHeight: '75vh',
+ loadingText: '稍等,正在加载中哦',
errorText: '哎呀,加载失败了呢~',
emptyText: '啊偶,暂时没有数据呢~',
loadingSubText: '',
@@ -63,7 +63,7 @@ const statusScene = computed(() => {
@@ -83,8 +83,8 @@ const statusScene = computed(() => {
{{ statusScene.mainText }}
-
-
+
+
@@ -98,9 +98,6 @@ const statusScene = computed(() => {
+ &.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);
+ }
+ }
+
\ No newline at end of file
diff --git a/src/components/uh-plugin-unavailable/uh-plugin-unavailable.vue.bak b/src/components/uh-plugin-unavailable/uh-plugin-unavailable.vue.bak
new file mode 100644
index 0000000..03dd947
--- /dev/null
+++ b/src/components/uh-plugin-unavailable/uh-plugin-unavailable.vue.bak
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+ {{ pluginInfo.name }}
+
+
+
+ 未安装/启用插件
+
+
+
+ {{ pluginInfo.desc }}
+
+
+
+ {{ errorText }}
+
+
+
+ 插件地址:{{ pluginInfo.url }}
+
+
+
+
+
+ 复制地址
+
+
+
+
+
+ 复制地址
+
+
+ 提交反馈
+
+
+
+
+
+
+
+ 刷新试试
+
+
+
+
+ 提示:请确保 Halo 博客已安装相关插件
+
+
+
+
+
diff --git a/src/hooks/useMaintenanceIntercept.ts b/src/hooks/useMaintenanceIntercept.ts
index 9fd919c..ee4ee44 100644
--- a/src/hooks/useMaintenanceIntercept.ts
+++ b/src/hooks/useMaintenanceIntercept.ts
@@ -6,7 +6,6 @@
* 维护页按 reason 展示默认(未配置维护信息)或配置文案。设计见插件
* .docs/maintenance-config-design.md §8。
*/
-import { usePluginAvailable } from '@/utils/plugin'
import { useAppConfigStore } from '@/store/appConfig'
/** 维护拦截原因:plugin 主插件未激活 / maintenance 维护模式开启 */
@@ -33,14 +32,16 @@ export const MAINTENANCE_PLUGIN_ID = 'plugin-uni-halo'
*/
export function useMaintenanceIntercept() {
const appConfigStore = useAppConfigStore()
+ /** 主插件可用性 hook(checkIntercept 内 await check 后读取 available) */
+ const { available: pluginAvailable, check: checkPluginAvailable } = usePluginAvailable(MAINTENANCE_PLUGIN_ID)
/**
* 检查是否命中拦截(插件可用性 + 维护模式)。
* @param force 是否强制刷新配置(默认 false 走 bootstrap TTL 缓存)
*/
async function checkIntercept(force = false): Promise {
- const pluginAvailable = await usePluginAvailable(MAINTENANCE_PLUGIN_ID)
- if (!pluginAvailable)
+ await checkPluginAvailable()
+ if (!pluginAvailable.value)
return { intercepted: true, reason: 'plugin' }
const { ok } = await appConfigStore.bootstrap({ force })
diff --git a/src/utils/plugin.ts b/src/hooks/usePluginAvailable.ts
similarity index 73%
rename from src/utils/plugin.ts
rename to src/hooks/usePluginAvailable.ts
index b765f97..d461079 100644
--- a/src/utils/plugin.ts
+++ b/src/hooks/usePluginAvailable.ts
@@ -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 { checkUrl } from '@/utils/url'
@@ -124,10 +139,26 @@ export async function checkNeedPluginAvailable(pluginId: string): Promise {
- return checkNeedPluginAvailable(pluginId)
+export function usePluginAvailable(pluginId: string, initial = true) {
+ /** 插件是否可用(默认 true,避免首帧闪现插件不可用占位;需要先置 false 的页面传 initial=false) */
+ const available = ref(initial)
+ /** 是否校验中 */
+ const checking = ref(false)
+
+ /**
+ * 执行插件可用性校验(刷新 available)
+ * @returns 当前是否可用(与 available.value 一致,便于一次性调用方直接取返回值)
+ */
+ async function check(): Promise {
+ checking.value = true
+ try {
+ available.value = await checkNeedPluginAvailable(pluginId)
+ return available.value
+ }
+ finally {
+ checking.value = false
+ }
+ }
+
+ return { available, checking, check }
}
diff --git a/src/pages-blog/article-detail/article-detail.vue b/src/pages-blog/article-detail/article-detail.vue
index 8fcb1df..19d425f 100644
--- a/src/pages-blog/article-detail/article-detail.vue
+++ b/src/pages-blog/article-detail/article-detail.vue
@@ -5,8 +5,10 @@ import { getPostByName, getPostCommentReplyList, postTrackersCounter, submitUpvo
import { createVerificationCode, requestRestrictReadCheck } from '@/api/uni-halo'
import { formatTime } from '@/utils/formatTime'
import { useAppConfigStore } from '@/store/appConfig'
+import { useFavoritesStore } from '@/store/favorites'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
+import { buildPostFavoriteItem } from '@/utils/favorite'
import { checkPostRestrictRead, copyToClipboard, getRestrictReadTypeName, getShowableContent } from '@/utils/restrictRead'
import { getDomainOnly } from '@/utils/urlParams'
import { markdownConfig } from '@/config/markdown'
@@ -23,6 +25,7 @@ definePage({
})
const appConfigStore = useAppConfigStore()
+const favoritesStore = useFavoritesStore()
const settingStore = useSettingStore()
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() {
const annotations = result.value?.metadata?.annotations
@@ -613,10 +632,11 @@ const globalAppSettings = computed(() => settingStore.settings)
- 收藏
+ {{ hasFavorited() ? '已收藏' : '收藏' }}
diff --git a/src/pages-blog/data-visual/data-visual.vue b/src/pages-blog/data-visual/data-visual.vue
index cf0f387..d37ec37 100644
--- a/src/pages-blog/data-visual/data-visual.vue
+++ b/src/pages-blog/data-visual/data-visual.vue
@@ -1,280 +1,256 @@
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
- 标签统计
- (全部标签的文章数量占比)
-
-
-
-
-
-
-
+
+
+
+
+
+ 标签统计
+
+
+ (全部标签的文章数量占比)
+
+
+
+
+
+
+
+
-
-
-
-
- 分类统计
- (全部分类的文章数量占比)
-
-
-
-
-
-
-
+
+
+
+ 分类统计
+
+
+ (全部分类的文章数量占比)
+
+
+
+
+
+
+
+
-
-
-
-
- 文章发布趋势
- (按日期统计文章发布数量)
-
-
-
-
-
-
-
+
+
+
+ 文章发布趋势
+
+
+ (按日期统计文章发布数量)
+
+
+
+
+
+
+
+
-
-
-
-
- 评论活跃用户
- (按评论作者统计评论数量)
-
-
-
-
-
-
-
+
+
+
+ 评论活跃用户
+
+
+ (按评论作者统计评论数量)
+
+
+
+
+
+
+
+
-
-
-
-
- 热门文章前10
- (按访问量排序的热门文章)
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ 热门文章前10
+
+
+ (按访问量排序的热门文章)
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages-blog/favorites/favorites.vue b/src/pages-blog/favorites/favorites.vue
new file mode 100644
index 0000000..09631a6
--- /dev/null
+++ b/src/pages-blog/favorites/favorites.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
+
+
+ {{ tab.label }}
+ ({{ tab.count }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title || '未命名' }}
+
+
+ {{ item.content }}
+
+
+
+
+
+
+
+ {{ item.owner.displayName }}
+ · 收藏于 {{ formatCollectTime(item.createTime) }}
+
+
+ 详情
+ 删除
+
+
+
+
+
+
+
+
+
+
+ {{ item.content || '(暂无内容)' }}
+
+
+
+
+
+ {{ item.owner.displayName }}
+ · 收藏于 {{ formatCollectTime(item.createTime) }}
+
+
+ 详情
+ 删除
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages-blog/friend-links/friend-links.vue b/src/pages-blog/friend-links/friend-links.vue
index 9d15fb2..c60c6e0 100644
--- a/src/pages-blog/friend-links/friend-links.vue
+++ b/src/pages-blog/friend-links/friend-links.vue
@@ -12,7 +12,7 @@ import { getMiniProgramLinkGroupedList } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
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 { IMiniProgramLink, IMiniProgramLinkGroupVo } from '@/api/types/uni-halo'
@@ -32,10 +32,10 @@ const globalAppSettings = computed(() => settingStore.settings)
/* ---------------- 依赖插件 ---------------- */
/** 站点 tab:plugin-links */
const sitePluginId = NeedPluginIds.PluginLinks
-const sitePluginAvailable = ref(true)
+const { available: sitePluginAvailable, check: checkSitePluginAvailable } = usePluginAvailable(sitePluginId)
/** 小程序 tab:plugin-uni-halo */
const miniPluginId = NeedPluginIds.PluginUniHalo
-const miniPluginAvailable = ref(true)
+const { available: miniPluginAvailable, check: checkMiniPluginAvailable } = usePluginAvailable(miniPluginId)
/* ---------------- tabs ---------------- */
const activeTabIndex = ref(0)
@@ -268,9 +268,9 @@ function handleSaveMiniProgramCode(link: IMiniProgramLink) {
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
- ;[sitePluginAvailable.value, miniPluginAvailable.value] = await Promise.all([
- usePluginAvailable(sitePluginId),
- usePluginAvailable(miniPluginId),
+ await Promise.all([
+ checkSitePluginAvailable(),
+ checkMiniPluginAvailable(),
])
if (sitePluginAvailable.value)
handleGetLinkGroupData()
diff --git a/src/pages-blog/moment-detail/moment-detail.vue b/src/pages-blog/moment-detail/moment-detail.vue
index 3758a8c..dbfed16 100644
--- a/src/pages-blog/moment-detail/moment-detail.vue
+++ b/src/pages-blog/moment-detail/moment-detail.vue
@@ -7,9 +7,11 @@
*/
import { computed, ref } from 'vue'
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 { useFavoritesStore } from '@/store/favorites'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
+import { buildMomentFavoriteItem } from '@/utils/favorite'
import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
@@ -27,6 +29,7 @@ definePage({
})
const appConfigStore = useAppConfigStore()
+const favoritesStore = useFavoritesStore()
const haloConfigs = computed(() => appConfigStore.configs)
const bloggerInfo = computed(() => {
@@ -122,6 +125,83 @@ const calcMastheadMeta = computed(() => {
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([])
+
+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 }[]) {
stopAllVideos()
@@ -329,9 +409,49 @@ onShareTimeline(() => ({
+
+
+
+
+
+
+ 点赞
+
+
+
+
+ 评论
+
+
+
+
+ {{ momentFavorited ? '已收藏' : '收藏' }}
+
+
+
+
+
+
+
diff --git a/src/pages-blog/search/search.vue b/src/pages-blog/search/search.vue
index 64f7df9..cf7823f 100644
--- a/src/pages-blog/search/search.vue
+++ b/src/pages-blog/search/search.vue
@@ -7,7 +7,6 @@ import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getPostListByKeyword } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
-import { usePluginAvailable } from '@/utils/plugin'
import { markdownConfig } from '@/config/markdown'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { debounce } from '@/utils/debounce'
@@ -24,7 +23,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
/** 依赖插件(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')
@@ -122,7 +121,7 @@ function handleToTopPage(duration = 500) {
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
- uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
+ await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
diff --git a/src/pages-blog/votes/votes.vue b/src/pages-blog/votes/votes.vue
index 870a3e2..e5fd71f 100644
--- a/src/pages-blog/votes/votes.vue
+++ b/src/pages-blog/votes/votes.vue
@@ -7,7 +7,6 @@ import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getVoteList } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
-import { usePluginAvailable } from '@/utils/plugin'
import type { IVoteItem } from '@/api/types/uni-halo'
definePage({
@@ -22,7 +21,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
/** 依赖插件(plugin-vote) */
const uniHaloPluginId = 'plugin-vote'
-const uniHaloPluginAvailable = ref(true)
+const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
@@ -84,7 +83,7 @@ function handleToTopPage(duration = 500) {
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
- uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
+ await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
diff --git a/src/pages/maintenance/maintenance.vue b/src/pages/maintenance/maintenance.vue
index a7e8ec2..1cd2d94 100644
--- a/src/pages/maintenance/maintenance.vue
+++ b/src/pages/maintenance/maintenance.vue
@@ -3,7 +3,6 @@
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig'
import { checkUrl } from '@/utils/url'
- import { usePluginAvailable } from '@/utils/plugin'
import type { IPublicMaintenance } from '@/api/types/uni-halo'
definePage({
@@ -23,6 +22,8 @@
const RECOVERY_POLL_INTERVAL = 30 * 1000
const store = useAppConfigStore()
+ /** 插件可用性(拦截恢复检测用) */
+ const { check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
const viewState = ref('loading')
const maintenance = ref(null)
@@ -146,7 +147,7 @@
try {
await store.bootstrap({ force: true })
if (fromReason.value === 'plugin') {
- const available = await usePluginAvailable(uniHaloPluginId)
+ const available = await checkPluginAvailable()
if (!available) {
const info = store.configs.maintenance
if (info) {
diff --git a/src/pages/tabbar/about/about.vue b/src/pages/tabbar/about/about.vue
index 872ec13..2c9a28d 100644
--- a/src/pages/tabbar/about/about.vue
+++ b/src/pages/tabbar/about/about.vue
@@ -5,13 +5,13 @@
* 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title + 彩色图标块)
*/
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 { useAppConfigStore } from '@/store/appConfig'
+import { useFavoritesStore } from '@/store/favorites'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { checkHasAdminLogin } from '@/utils/auth'
import { t } from '@/locale'
-import { usePluginAvailable } from '@/utils/plugin'
import type { IBlogStats } from '@/api/types/halo'
definePage({
@@ -23,10 +23,13 @@ definePage({
})
const appConfigStore = useAppConfigStore()
+const favoritesStore = useFavoritesStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
+/** 数据看板插件可用性(供导航项显隐判断) */
+const { check: checkDataVisualPlugin } = usePluginAvailable('plugin-data-statistics')
/* ---------------- 计算属性 ---------------- */
const bloggerInfo = computed(() => {
@@ -116,10 +119,28 @@ function toSolidColor(rgba: string) {
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() {
- const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
+ const dataVisualAvailable = await checkDataVisualPlugin()
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',
title: '数据看板',
@@ -217,6 +238,7 @@ async function handleGetNavList() {
group: 'more',
},
]
+ syncFavoritesNavText()
}
/* ---------------- 数据加载 ---------------- */
@@ -269,6 +291,11 @@ watch(haloConfigs, () => {
handleGetData()
+// 从收藏页返回/切回时刷新收藏数文案
+onShow(() => {
+ syncFavoritesNavText()
+})
+
onPullDownRefresh(() => {
handleGetData()
})
diff --git a/src/pages/tabbar/gallery/gallery.vue b/src/pages/tabbar/gallery/gallery.vue
index 50d8576..a759c50 100644
--- a/src/pages/tabbar/gallery/gallery.vue
+++ b/src/pages/tabbar/gallery/gallery.vue
@@ -9,7 +9,7 @@
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
- import { usePluginAvailable } from '@/utils/plugin'
+ import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { IPhoto, IPhotoGroup } from '@/api/types/halo'
definePage({
@@ -27,10 +27,10 @@
/** 依赖插件(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[] }>({
activeIndex: 0,
list: [],
@@ -58,14 +58,12 @@
handleGetData(true)
}
else {
- loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.stopPullDownRefresh()
}
}
catch (e) {
console.error(e)
- loading.value = 'error'
category.value = { activeIndex: 0, list: [] }
}
return
@@ -82,7 +80,6 @@
}
catch (e) {
console.error(e)
- loading.value = 'error'
category.value = { activeIndex: 0, list: [] }
}
}
@@ -94,7 +91,7 @@
}
if (!isLoadMore.value) {
- loading.value = 'loading'
+ updateLoadingStatus(DataLoadingStatusEnum.Loading)
}
loadMoreText.value = ''
@@ -110,12 +107,12 @@
? dataList.value.concat(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')
}
catch (err) {
console.error(err)
- loading.value = 'error'
+ updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed')
}
finally {
@@ -150,20 +147,18 @@
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
// 检查插件可用性
- uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
+ await checkPluginAvailable()
+ console.log('uniHaloPluginAvailable',uniHaloPluginAvailable.value)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
- })
-
- watch(galleryConfig, (newVal) => {
- if (!newVal)
- return
- uni.setNavigationBarTitle({ title: newVal.pageTitle || t('page.gallery.title') })
+
+
+ // 开始正常数据请求
handleGetCategory()
- }, { deep: true, immediate: true })
-
+ })
+
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
@@ -211,9 +206,8 @@
-
-
-
+
diff --git a/src/pages/tabbar/home/home.vue b/src/pages/tabbar/home/home.vue
index e1f6b4b..a222181 100644
--- a/src/pages/tabbar/home/home.vue
+++ b/src/pages/tabbar/home/home.vue
@@ -7,6 +7,7 @@
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
import { useMaintenanceIntercept } from '@/hooks/useMaintenanceIntercept'
+ import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { IPost } from '@/api/types/halo'
definePage({
@@ -28,7 +29,7 @@
const haloConfigs = computed(() => appConfigStore.configs)
/* ---------------- 状态 ---------------- */
- const loading = ref<'loading' | 'success' | 'error'>('loading')
+ const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const articleList = ref([])
@@ -79,12 +80,12 @@
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
return item
})
- loading.value = 'success'
+ updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
loadMoreText.value = t('common.noMore')
}
catch (err) {
console.error('获取审核文章失败', err)
- loading.value = 'error'
+ updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed')
}
finally {
@@ -95,7 +96,7 @@
}
if (!isLoadMore.value) {
- loading.value = 'loading'
+ updateLoadingStatus(DataLoadingStatusEnum.Loading)
}
loadMoreText.value = t('common.loading')
@@ -108,11 +109,11 @@
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
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')
}
catch (err) {
- loading.value = 'error'
+ updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed')
console.error('获取文章失败', err)
}
@@ -147,14 +148,14 @@
},
})
}
-
- function init(){
+
+ function init() {
if (!intercepted.value) {
handleQuery()
}
}
init()
-
+
/* ---------------- 生命周期 ---------------- */
// 维护检查
@@ -183,55 +184,48 @@
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 最新内容
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 最新内容
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {{ loadMoreText }}
+
+
+
-
-
-
-
-
- {{ loadMoreText }}
-
-
-
-
-
diff --git a/src/pages/tabbar/moments/moments.vue b/src/pages/tabbar/moments/moments.vue
index 1d84ad3..f135514 100644
--- a/src/pages/tabbar/moments/moments.vue
+++ b/src/pages/tabbar/moments/moments.vue
@@ -8,12 +8,14 @@
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getMomentList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
+ import { useFavoritesStore } from '@/store/favorites'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
+ import { buildMomentFavoriteItem } from '@/utils/favorite'
import { generateUUID } from '@/utils/uuid'
- import { formatTime as formatTimeUtil } from '@/utils/formatTime'
+ import { formatTime } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
import { t } from '@/locale'
- import { usePluginAvailable } from '@/utils/plugin'
+ import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import { markdownConfig } from '@/config/markdown'
import type { IMoment } from '@/api/types/halo'
@@ -21,12 +23,11 @@
style: {
navigationBarTitleText: '瞬间',
enablePullDownRefresh: true,
- // 下拉/回弹露出的窗口底色对齐页面底色
- backgroundColor: '#f6f3ee',
},
})
const appConfigStore = useAppConfigStore()
+ const favoritesStore = useFavoritesStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
@@ -47,13 +48,13 @@
/** 依赖插件(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 hasNext = ref(false)
- /** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
+ /** 列表卡片 */
type MomentCard = IMoment & {
images ?: { type ?: string, url : string }[]
videos ?: { id ?: string, url : string }[]
@@ -66,13 +67,6 @@
const videoContexts = ref>({})
const currentVideoId = ref(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 = /]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
@@ -116,14 +110,14 @@
nextTick(() => {
createVideoContexts(tempItems)
})
- loading.value = 'success'
+ updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
}
catch (err) {
console.error(err)
- loading.value = 'error'
+ updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed')
}
return
@@ -131,13 +125,12 @@
uni.showLoading({ mask: true, title: t('common.loading') })
if (!isLoadMore.value) {
- loading.value = 'loading'
+ updateLoadingStatus(DataLoadingStatusEnum.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
@@ -148,6 +141,7 @@
dataList.value = isLoadMore.value
? dataList.value.concat(tempItems)
: tempItems
+ updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
nextTick(() => {
createVideoContexts(tempItems)
@@ -155,7 +149,7 @@
}
catch (err) {
console.error(err)
- loading.value = 'error'
+ updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed')
}
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) {
uni.pageScrollTo({
scrollTop: 0,
@@ -228,14 +234,13 @@
/** 格式化瞬间时间 */
function formatMomentTime(time ?: string) : string {
- // 与旧项目一致:yyyy年MM月dd日 星期w
- return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
+ return time ? formatTime({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uni.setNavigationBarTitle({ title: t('page.moments.title') })
- uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
+ await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
@@ -256,8 +261,7 @@
})
onReachBottom(() => {
- if (!uniHaloPluginAvailable.value)
- return
+ if (!uniHaloPluginAvailable.value) { return }
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
@@ -279,8 +283,8 @@
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员" @on-refresh="handleGetData" />
-
+
-
+
@@ -372,9 +376,10 @@
评论 {{ moment.stats.totalComment || 0 }}
-
-
- 收藏
+
+
+ {{ isMomentFavorite(moment) ? '已收藏' : '收藏' }}
diff --git a/src/pages/tabbar/moments/moments.vue.glass.bak b/src/pages/tabbar/moments/moments.vue.glass.bak
deleted file mode 100644
index e96a4e7..0000000
--- a/src/pages/tabbar/moments/moments.vue.glass.bak
+++ /dev/null
@@ -1,423 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ moment.owner?.displayName || bloggerInfo.nickname }}
-
-
- {{ formatMomentTime(moment.spec.releaseTime) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- # {{ tag }}
-
-
-
-
-
-
-
- {{ moment.stats.upvote || 0 }}
-
-
-
- {{ moment.stats.totalComment || 0 }}
-
-
-
-
-
-
-
-
- {{ loadMoreText }}
-
-
-
-
-
-
diff --git a/src/store/favorites.test.ts b/src/store/favorites.test.ts
new file mode 100644
index 0000000..07afa82
--- /dev/null
+++ b/src/store/favorites.test.ts
@@ -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)
+ })
+})
diff --git a/src/store/favorites.ts b/src/store/favorites.ts
new file mode 100644
index 0000000..d80da7b
--- /dev/null
+++ b/src/store/favorites.ts
@@ -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([])
+
+ /** 过滤脏数据后的合法列表 */
+ 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,
+ },
+)
diff --git a/src/store/index.ts b/src/store/index.ts
index da58493..5ee07e0 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -16,6 +16,7 @@ setActivePinia(store)
export default store
export * from './appConfig'
+export * from './favorites'
export * from './halo'
export * from './setting'
// 模块统一导出
diff --git a/src/utils/favorite.ts b/src/utils/favorite.ts
new file mode 100644
index 0000000..b4abe67
--- /dev/null
+++ b/src/utils/favorite.ts
@@ -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
+ 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
+}
diff --git a/src/utils/text.ts b/src/utils/text.ts
new file mode 100644
index 0000000..0a03322
--- /dev/null
+++ b/src/utils/text.ts
@@ -0,0 +1,51 @@
+/**
+ * 文本工具:HTML 剥离为纯文本 + 摘要截断
+ * 跨端实现(小程序无 DOM),仅用正则处理,不依赖 DOMParser
+ */
+
+/** HTML 实体解码(覆盖常见实体即可) */
+const HTML_ENTITY_MAP: Record = {
+ ' ': ' ',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ ''': '\'',
+ '&': '&',
+}
+
+/** 行首 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)
+}