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:
@@ -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
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* 插件清单与可用性检查(源自旧项目 utils/plugin.js,TS 化)
|
||||
*/
|
||||
import { checkPluginAvailable } from '@/api/halo'
|
||||
import { checkUrl } from '@/utils/url'
|
||||
|
||||
/** 依赖插件 ID 常量 */
|
||||
export const NeedPluginIds = Object.freeze({
|
||||
PluginUniHalo: 'plugin-uni-halo',
|
||||
PluginPhotos: 'PluginPhotos',
|
||||
PluginLinks: 'PluginLinks',
|
||||
PluginMoments: 'PluginMoments',
|
||||
PluginSearchWidget: 'PluginSearchWidget',
|
||||
PluginCommentWidget: 'PluginCommentWidget',
|
||||
PluginVote: 'vote',
|
||||
PluginDataStatistics: 'data-statistics',
|
||||
})
|
||||
|
||||
export interface IPluginInfo {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
logo: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/** 依赖插件清单 */
|
||||
export const NeedPlugins = new Map<string, IPluginInfo>([
|
||||
[
|
||||
NeedPluginIds.PluginUniHalo,
|
||||
{
|
||||
id: 'plugin-uni-halo',
|
||||
name: 'UniHalo配置',
|
||||
desc: 'uni-halo 核心插件,未安装和启用的情况下,将无法使用 uni-halo,请检查是否已安装和启用',
|
||||
logo: checkUrl('/plugins/plugin-uni-halo/assets/logo.png'),
|
||||
url: 'https://www.halo.run/store/apps/app-ryemX',
|
||||
},
|
||||
],
|
||||
[
|
||||
NeedPluginIds.PluginPhotos,
|
||||
{
|
||||
id: 'PluginPhotos',
|
||||
name: '图库管理',
|
||||
desc: '图库功能模块所需要的插件',
|
||||
logo: checkUrl('/plugins/PluginPhotos/assets/logo.svg'),
|
||||
url: 'https://www.halo.run/store/apps/app-BmQJW',
|
||||
},
|
||||
],
|
||||
[
|
||||
NeedPluginIds.PluginLinks,
|
||||
{
|
||||
id: 'PluginLinks',
|
||||
name: '链接管理',
|
||||
desc: '链接管理模块,用于网站友情链接功能模块',
|
||||
logo: checkUrl('/plugins/PluginLinks/assets/logo.svg'),
|
||||
url: 'https://www.halo.run/store/apps/app-hfbQg',
|
||||
},
|
||||
],
|
||||
[
|
||||
NeedPluginIds.PluginMoments,
|
||||
{
|
||||
id: 'PluginMoments',
|
||||
name: '瞬间',
|
||||
desc: '提供一个轻量级的内容图文、视频、音频等内容展示',
|
||||
logo: checkUrl('/plugins/PluginMoments/assets/logo.svg'),
|
||||
url: 'https://www.halo.run/store/apps/app-SnwWD',
|
||||
},
|
||||
],
|
||||
[
|
||||
NeedPluginIds.PluginSearchWidget,
|
||||
{
|
||||
id: 'PluginSearchWidget',
|
||||
name: '搜索组件',
|
||||
desc: '为应用提供统一的搜索组件',
|
||||
logo: checkUrl('/plugins/PluginSearchWidget/assets/logo.svg'),
|
||||
url: 'https://www.halo.run/store/apps/app-DlacW',
|
||||
},
|
||||
],
|
||||
[
|
||||
NeedPluginIds.PluginCommentWidget,
|
||||
{
|
||||
id: 'PluginCommentWidget',
|
||||
name: '评论组件',
|
||||
desc: '为用户前台提供完整的评论解决方案',
|
||||
logo: checkUrl('/plugins/PluginCommentWidget/assets/logo.svg'),
|
||||
url: 'https://www.halo.run/store/apps/app-YXyaD',
|
||||
},
|
||||
],
|
||||
[
|
||||
NeedPluginIds.PluginVote,
|
||||
{
|
||||
id: 'vote',
|
||||
name: '投票管理',
|
||||
desc: '投票模块所需要的插件,用于展示投票和提交投票',
|
||||
logo: checkUrl('/plugins/vote/assets/logo.png'),
|
||||
url: 'https://www.halo.run/store/apps/app-veyvzyhv',
|
||||
},
|
||||
],
|
||||
[
|
||||
NeedPluginIds.PluginDataStatistics,
|
||||
{
|
||||
id: 'data-statistics',
|
||||
name: '数据看板',
|
||||
desc: '为 Halo2 提供强大的数据可视化统计功能,支持 Umami 流量统计、uptime、网站内部数据图表(标签、分类、文章趋势、评论排行、热门文章等)',
|
||||
logo: checkUrl('/plugins/data-statistics/assets/logo.svg'),
|
||||
url: 'https://www.halo.run/store/apps/app-rtnbbgfk',
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
/**
|
||||
* 检查插件是否启用、安装
|
||||
* @param pluginId 插件 id
|
||||
* @returns true = 安装、启用;false = 未安装启用
|
||||
*/
|
||||
export async function checkNeedPluginAvailable(pluginId: string): Promise<boolean> {
|
||||
try {
|
||||
const available = await checkPluginAvailable(pluginId)
|
||||
return available?.data?.available !== false
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`检查插件 ${pluginId} 可用性失败`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查插件可用性(供页面 onLoad 使用,源自 uh-plugin-unavailable 组件,移出避免 script setup export)
|
||||
* @param pluginId 插件 id
|
||||
*/
|
||||
export async function usePluginAvailable(pluginId: string): Promise<boolean> {
|
||||
return checkNeedPluginAvailable(pluginId)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 文本工具:HTML 剥离为纯文本 + 摘要截断
|
||||
* 跨端实现(小程序无 DOM),仅用正则处理,不依赖 DOMParser
|
||||
*/
|
||||
|
||||
/** HTML 实体解码(覆盖常见实体即可) */
|
||||
const HTML_ENTITY_MAP: Record<string, string> = {
|
||||
' ': ' ',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': '\'',
|
||||
'&': '&',
|
||||
}
|
||||
|
||||
/** 行首 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)
|
||||
}
|
||||
Reference in New Issue
Block a user