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

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

1. 新增mp-html、qs等生产依赖,补全项目基础库
2. 新增平台判断、缓存、工具函数等通用工具集
3. 新增标签页、网站浏览页、关于页等业务页面
4. 新增分类卡片、通知弹窗等业务组件
5. 新增uts-progressNotification、liu-poster、uhalo-upgrade等uni模块
6. 补充audio/video组件样式补件,修复uni-components路径缺失问题
7. 新增环境变量Halo个人令牌配置项
8. 重构store导出结构,新增appConfig/halo/setting三个状态模块
9. 新增tsconfig编译目标配置,适配更高版本ES语法
This commit is contained in:
小莫唐尼
2026-08-31 19:56:16 +08:00
parent ba5b77568b
commit 067f3ed98a
147 changed files with 20866 additions and 325 deletions
+37
View File
@@ -0,0 +1,37 @@
/**
* 认证信息工具(请求头来源)
* 源自旧项目 utils/auth.js,按需命名导出
*/
/** 管理员登录 token 存储 key(旧项目 APP_ADMIN_LOGIN_TOKEN) */
const ADMIN_LOGIN_TOKEN_KEY = 'APP_ADMIN_LOGIN_TOKEN'
/**
* 是否已管理员登录(登录功能暂缓,保留判断逻辑)
*/
export function checkHasAdminLogin(): boolean {
return !!uni.getStorageSync(ADMIN_LOGIN_TOKEN_KEY)
}
/**
* 获取微信 openid(登录流程写入 storage)
*/
export function getOpenid(): string {
return uni.getStorageSync('openid') || ''
}
/**
* 获取匿名访客邮箱(nologin-email 请求头来源)
*/
export function getNologinEmail(): string {
const visitor = uni.getStorageSync('Visitor')
if (!visitor)
return ''
try {
const v = JSON.parse(visitor)
return v.email || v.author || ''
}
catch {
return ''
}
}
+148
View File
@@ -0,0 +1,148 @@
/**
* 检查更新工具(源自旧项目 uni_modules/uhalo-upgrade/utils/check-update.ts,新建复刻)
* 业务侧调用 uni-halo 的 checkVersion 接口,有新版时弹窗提示并下载安装(APP 端)
* 使用方式:App.vue onLaunch 中调用 checkUpdate(import.meta.env.VITE_SERVER_BASEURL)
*/
import { checkVersion } from '@/api/uni-halo'
import type { IUpdateCheckRes } from '@/api/types/uni-halo'
/** 升级弹窗(uni.showModal 实现,不依赖 uni_modules 弹窗页) */
function updateUseModal(packageInfo: IUpdateCheckRes): void {
// #ifdef APP
const {
title = '版本更新',
contents = '检测到新版本,是否立即更新?',
is_mandatory = false,
url = '',
type,
platform = [],
} = packageInfo
if (!url) {
console.error('更新地址为空,无法更新', packageInfo)
return
}
const isWGT = type === 'wgt'
const isiOS = !isWGT && platform.includes('ios')
const confirmText = isiOS ? '立即跳转更新' : '立即下载更新'
uni.showModal({
title,
content: contents,
showCancel: !is_mandatory,
confirmText,
success: (res) => {
if (res.cancel)
return
if (isiOS) {
// iOS 平台跳转 AppStore
plus.runtime.openURL(url)
return
}
uni.showToast({
title: '后台下载中……',
duration: 1000,
})
// wgt 和安卓下载更新
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode !== 200) {
console.error('下载安装包失败')
return
}
// 下载好直接安装,下次启动生效
plus.runtime.install(res.tempFilePath, {
force: false,
}, () => {
if (is_mandatory) {
// 强制更新:安装成功后重启 app
// #ifdef APP-PLUS
plus.runtime.restart()
// #endif
// #ifdef APP-HARMONY
uni.showModal({
title: '安装成功',
content: '请手动重启应用',
showCancel: false,
success: () => {
plus.runtime.quit()
},
})
// #endif
return
}
uni.showModal({
title: '安装成功是否重启?',
success: (r) => {
if (r.confirm) {
// #ifdef APP-PLUS
plus.runtime.restart()
// #endif
// #ifdef APP-HARMONY
plus.runtime.quit()
// #endif
}
},
})
}, (err) => {
uni.showModal({
title: '更新失败',
content: (err as Error).message,
showCancel: false,
})
})
},
})
},
})
// #endif
}
/**
* 检查更新并处理升级
* @param baseUrl Halo 站点地址
* @returns 检查结果(code > 0 有更新,code === 0 无更新,code < 0 检查失败)
*/
export async function checkUpdate(baseUrl: string): Promise<IUpdateCheckRes> {
try {
const res = await checkVersion(baseUrl)
const body = res.data || (res as unknown as IUpdateCheckRes)
const code = Number(body.code || 0)
// 静默更新(仅 wgt 热更新,后台下载安装)
if (code > 0 && body.is_silently) {
// #ifdef APP
if (body.url) {
uni.downloadFile({
url: body.url,
success: (r) => {
if (r.statusCode === 200) {
plus.runtime.install(r.tempFilePath, {
force: false,
})
}
},
})
}
// #endif
return body
}
if (code > 0) {
updateUseModal(body)
}
else if (code < 0) {
console.error('检查更新失败', body.message)
}
return body
}
catch (err) {
console.error('检查更新异常', err)
return { code: -1, message: (err as Error).message || '检查更新失败' }
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Cookie 处理工具(源自旧项目 utils/cookies.js,按需命名导出)
*/
/**
* 从带换行的 cookie 原始串提取某一条 cookie(带;结尾)
* @param cookieRaw set-cookie 原始字符串
* @param cookieKey cookie 名称,例如 "comment-widget-captcha"
* @returns 清理换行后的 cookie 片段,没匹配返回 ''
*/
export function extractCookieItem(cookieRaw: string, cookieKey: string): string {
if (!cookieRaw || !cookieKey)
return ''
// 转义正则特殊字符
const keyEscaped = cookieKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const reg = new RegExp(`${keyEscaped}=[\\s\\S]*?;`)
const m = cookieRaw.match(reg)
return m ? m[0].replace(/\r?\n/g, '') : ''
}
/**
* 获取 header 中指定 key 的值,忽略大小写
* @param headers HTTP header 对象
* @param name HTTP header 键名
* @returns HTTP header 值,没找到返回 ''
*/
export function getHeaderCaseInsensitive(headers: Record<string, string> | undefined, name: string): string {
if (!headers || typeof headers !== 'object')
return ''
const lowerName = name.toLowerCase()
const key = Object.keys(headers).find(k => k.toLowerCase() === lowerName)
return key ? headers[key] : ''
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 时间格式化工具(源自旧项目 common/filters/index.js 的 formatTime,改 utils 按需导出)
* 支持格式化 yyyy年MM月dd日 HH点mm分ss秒 星期w q季
* 兼容对象形式传入 { d: '2021-06-04', f: 'yyyy年' }(d 必传,f 默认 yyyy-MM-dd HH:mm:ss)
*/
export type FormatTimeInput = string | number | Date | { d: string | number | Date, f?: string }
/**
* 时间格式化
* @param data 时间戳/日期字符串,或 { d, f } 对象
* @returns 格式化后的时间字符串
*/
export function formatTime(data: FormatTimeInput): string {
let dateTime = new Date(data as string | number | Date)
let fmt = 'yyyy-MM-dd HH:mm:ss'
// 对象形式传参:uniapp filter 不支持多参数,用对象传 { d, f }
if (dateTime.toString() === 'Invalid Date') {
if (typeof data === 'object' && data !== null && !(data instanceof Date)) {
const { d, f } = data
if (d == null || d === '') {
console.error('日期参数不正确,传入的参数列表:', data)
return ''
}
dateTime = new Date(d)
if (dateTime.toString() === 'Invalid Date') {
console.error('日期参数不正确,传入的参数列表:', data)
return '111'
}
if (Object.prototype.hasOwnProperty.call(data, 'f')) {
fmt = f || fmt
}
}
else {
console.error('日期参数不正确,传入的参数列表:', data)
return ''
}
}
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const seasons = ['冬', '春', '夏', '秋']
const o: Record<string, number | string> = {
'M+': dateTime.getMonth() + 1, // 月份
'd+': dateTime.getDate(), // 日
'H+': dateTime.getHours(), // 小时
'm+': dateTime.getMinutes(), // 分
's+': dateTime.getSeconds(), // 秒
'w+': weekDays[dateTime.getDay()], // 星期几
'q+': seasons[Math.floor((dateTime.getMonth() + 3) / 3)], // 季度
'S': dateTime.getMilliseconds(), // 毫秒
}
const yMatch = fmt.match(/(y+)/)
if (yMatch) {
fmt = fmt.replace(yMatch[0], (`${dateTime.getFullYear()}`).substr(4 - yMatch[0].length))
}
for (const k in o) {
const match = fmt.match(new RegExp(`(?:${k})`))
if (match) {
fmt = fmt.replace(match[0], match[0].length === 1
? String(o[k])
: (`00${o[k]}`).substr(String(o[k]).length))
}
}
return fmt
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 图片缓存工具(源自旧项目 utils/imageCache.js,按需命名导出)
* 下载图片到本地缓存(APP 端),返回本地路径
*/
import { checkIsUrl } from './url'
const CACHE_PREFIX = 'IMAGE_CACHE_'
/**
* 缓存图片(存在则直接返回缓存路径,否则下载)
* @param url 图片远程地址
* @returns 可用的本地/远程路径
*/
export function getCachedImage(url: string): Promise<string> {
return new Promise((resolve) => {
if (!checkIsUrl(url)) {
resolve(url)
return
}
const key = CACHE_PREFIX + url
// #ifdef APP-PLUS
const cached = uni.getStorageSync(key)
if (cached) {
resolve(cached)
return
}
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode === 200) {
try {
uni.setStorageSync(key, res.tempFilePath)
resolve(res.tempFilePath)
}
catch (e) {
console.error('图片缓存写入失败', e)
resolve(url)
}
}
else {
resolve(url)
}
},
fail: () => {
resolve(url)
},
})
// #endif
// #ifndef APP-PLUS
resolve(url)
// #endif
})
}
+4 -4
View File
@@ -112,7 +112,7 @@ export function getCurrentPageI18nKey() {
subPackages?.forEach((config) => {
config.pages?.forEach((cur) => {
allSubPages.push({
...cur,
...(cur as PageMetaDatum),
path: `/${config.root}/${cur.path}`,
})
})
@@ -141,9 +141,9 @@ export function getEnvBaseUrl() {
let baseUrl = import.meta.env.VITE_SERVER_BASEURL
// # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。
const WeixinDevelopBaseUrl= import.meta.env.VITE_SERVER_BASEURL__WEIXIN_DEVELOP
const WeixinTrialBaseUrl= import.meta.env.VITE_SERVER_BASEURL__WEIXIN_TRIAL
const WeixinReleaseBaseUrl=import.meta.env. VITE_SERVER_BASEURL__WEIXIN_RELEASE
const WeixinDevelopBaseUrl = import.meta.env.VITE_SERVER_BASEURL__WEIXIN_DEVELOP
const WeixinTrialBaseUrl = import.meta.env.VITE_SERVER_BASEURL__WEIXIN_TRIAL
const WeixinReleaseBaseUrl = import.meta.env.VITE_SERVER_BASEURL__WEIXIN_RELEASE
// 微信小程序端环境区分
if (isMpWeixin) {
+28
View File
@@ -0,0 +1,28 @@
/**
* JSON 解析工具(源自旧项目 utils/index.js 的 checkJsonAndParse,按需命名导出)
*/
export interface IParseResult<T = unknown> {
ok: boolean
jsonData: T
}
/**
* 安全解析 JSON 字符串(解析失败返回 ok:false,不抛异常)
* @param jsonStr 待解析字符串
*/
export function checkJsonAndParse<T = unknown>(jsonStr: string): IParseResult<T> {
try {
const jsonResult = JSON.parse(jsonStr)
return {
ok: true,
jsonData: jsonResult as T,
}
}
catch {
return {
ok: false,
jsonData: {} as T,
}
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 对象合并/克隆工具(源自旧项目 utils/index.js 的 deepMerge/deepClone,按需命名导出)
*/
/** 判断是否为普通对象(非数组、非 null) */
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* 深克隆
* @param obj 数据源
*/
export function deepClone<T>(obj: T): T {
if (Array.isArray(obj)) {
return obj.map(item => deepClone(item)) as unknown as T
}
if (obj && typeof obj === 'object') {
const clone: Record<string, unknown> = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clone[key] = deepClone((obj as Record<string, unknown>)[key])
}
}
return clone as T
}
return obj
}
/**
* 深合并(数组拼接,对象递归,其他直接覆盖)
* @param target 目标对象
* @param source 源对象
*/
export function deepMerge<T extends Record<string, unknown>, S extends Record<string, unknown>>(target: T, source: S): T & S {
const output: Record<string, unknown> = { ...target }
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
const targetValue = target[key]
const sourceValue = source[key]
if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
output[key] = [...targetValue, ...sourceValue]
}
else if (isObject(targetValue) && isObject(sourceValue)) {
output[key] = deepMerge(targetValue, sourceValue)
}
else {
output[key] = sourceValue
}
})
}
return output as T & S
}
+40
View File
@@ -0,0 +1,40 @@
/**
* 网络状态工具(源自旧项目 utils/network.js,按需命名导出)
* 网络可用性检查、断网提示、错误文案处理
*/
/**
* 检查网络是否可用(返回 Promise<boolean>)
*/
export function CheckNetWork(): Promise<boolean> {
return new Promise((resolve) => {
uni.getNetworkType({
success: (res) => {
if (res.networkType === 'none') {
uni.showToast({ icon: 'none', title: '当前网络不可用,请检查您的网络设置' })
resolve(false)
}
else {
resolve(true)
}
},
fail: () => {
uni.showToast({ icon: 'none', title: '当前网络不可用,请检查您的网络设置' })
resolve(false)
},
})
})
}
/**
* 处理请求错误文案
* @param error 错误对象或字符串
* @param defaultText 默认文案
*/
export function handleErrorMessage(error: unknown, defaultText = '请求失败,请重试!'): string {
if (typeof error === 'string')
return error
if (error instanceof Error)
return error.message || defaultText
return defaultText
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 页面通用工具(源自旧项目 common/mixins/index.js 的 methods/computed,改 utils 按需导出)
* haloConfig/haloPluginsConfig/globalAppSettings 对应新架构用 Pinia store 读取,此处不再提供
*/
import { useAppConfigStore } from '@/store/appConfig'
/** 动画索引(列表动画错峰) */
let aniWaitIndex = 0
/**
* 设置页面标题(默认取应用配置 startConfig.title)
* @param title 标题,为空时回退 uni-halo
*/
export function handleSetPageTitle(title?: string) {
const appConfigStore = useAppConfigStore()
const fallbackTitle = (appConfigStore.configs.appConfig as { startConfig?: { title?: string } } | undefined)?.startConfig?.title || 'uni-halo'
uni.setNavigationBarTitle({
title: title || fallbackTitle,
})
}
/**
* 页面返回顶部
* @param duration 滚动时长,默认 500
*/
export function handleToTopPage(duration = 500) {
const d = Number.isNaN(duration) ? 500 : duration
uni.pageScrollTo({
scrollTop: 0,
duration: d,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/** 初始化动画索引值(需要在每个页面调用) */
export function handleResetSetAniWaitIndex() {
aniWaitIndex = 0
}
/**
* 计算动画等待毫秒数(每 10 个重置为 1,与旧 mixins calcAniWait 一致)
* @param index 当前列表索引
* @returns 动画延迟毫秒数
*/
export function calcAniWait(index: number): number {
if ((index + 1) % 10 === 0) {
aniWaitIndex = 1
}
else {
aniWaitIndex += 1
}
return aniWaitIndex * 50
}
+13
View File
@@ -0,0 +1,13 @@
/**
* 平台判断工具(源自旧项目 common/mixins/index.js 的 _isWechat data,改 utils 按需导出)
*/
/** 是否微信小程序平台(条件编译,编译期确定) */
export const isWechat: boolean = (() => {
// #ifdef MP-WEIXIN
return true
// #endif
// #ifndef MP-WEIXIN
return false
// #endif
})()
+133
View File
@@ -0,0 +1,133 @@
/**
* 插件清单与可用性检查(源自旧项目 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)
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 随机数工具(源自旧项目 utils/random.js,按需命名导出)
*/
/**
* 生成指定范围内的随机整数(含边界)
* @param min 最小值(含)
* @param max 最大值(不含)
*/
export function getRandomNumberByRange(min: number, max: number): number {
return Math.floor(Math.random() * (max - min) + min)
}
/**
* 生成随机颜色(用于瞬间标签等)
*/
export function randomTagColor(): string {
const colors = ['orange', 'green', 'red', 'blue']
return colors[getRandomNumberByRange(0, colors.length)]
}
+135
View File
@@ -0,0 +1,135 @@
/**
* 受限阅读工具(源自旧项目 utils/restrictRead.js,按需命名导出)
* 处理文章受限内容(密码/验证码/登录/付费/评论)的检测与占位符替换
*/
import type { IPost } from '@/api/types/halo'
/** 受限阅读占位符 */
const RESTRICT_READ_PLACEHOLDER = 'restrict-read-placeholder'
/** 复制文本到剪贴板(旧 utils/index.js copyText) */
export function copyToClipboard(content: string, tips = '内容已复制成功!') {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
/** 转义字符串用于正则表达式 */
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** 判断字符串去除 HTML 标签后是否为空 */
function isHtmlEmpty(html: string): boolean {
return !html || !html.replace(/<[^>]+>/g, '').trim()
}
/**
* 检查文章是否受限
* @param post 文章对象
* @returns 是否受限
*/
export function checkPostRestrictRead(post: IPost): boolean {
const annotations = post?.metadata?.annotations
const restrictReadEnable = annotations?.restrictReadEnable
if (restrictReadEnable === 'false' || !restrictReadEnable)
return false
const restrictType = restrictReadEnable
const raw = post.content?.raw || ''
const startTag = `<!-- ${restrictType}:restrict-read-html-tpl start -->`
const endTag = `<!-- ${restrictType}:restrict-read-html-tpl end -->`
// 使用正则模糊匹配(允许前后有空白字符)
const startRegex = new RegExp(`\\s*${escapeRegExp(startTag)}\\s*`)
const endRegex = new RegExp(`\\s*${escapeRegExp(endTag)}\\s*`)
return startRegex.test(raw) && endRegex.test(raw)
}
/**
* 替换受限内容
* @param post 文章对象
* @param replacement 替换内容,默认空字符串
* @returns 替换后的 raw 文本
*/
export function replaceRestrictedContent(post: IPost, replacement = ''): string {
const annotations = post?.metadata?.annotations
const restrictReadEnable = annotations?.restrictReadEnable
if (restrictReadEnable === 'false' || !restrictReadEnable)
return post.content?.raw || ''
const restrictType = restrictReadEnable
const raw = post.content?.raw || ''
const startTag = `<!-- ${restrictType}:restrict-read-html-tpl start -->`
const endTag = `<!-- ${restrictType}:restrict-read-html-tpl end -->`
const startRegex = new RegExp(`\\s*${escapeRegExp(startTag)}\\s*`, 'g')
const endRegex = new RegExp(`\\s*${escapeRegExp(endTag)}\\s*`, 'g')
// 构造完整匹配的正则
const pattern = `${startRegex.source}(.*?)${endRegex.source}`
const regex = new RegExp(pattern, 'gs')
return raw.replace(regex, replacement)
}
/**
* 获取可展示的 HTML 内容块
* @param post 文章对象
* @returns 分割后的 HTML 片段数组
*/
export function getShowableContent(post: IPost): string[] {
const restrictEnabled = checkPostRestrictRead(post)
const rawContent = post?.content?.raw || ''
// 替换受限内容为占位符
const processedContent = restrictEnabled
? replaceRestrictedContent(post, RESTRICT_READ_PLACEHOLDER)
: rawContent
// 按占位符分割内容
const contentFragments = processedContent
.split(RESTRICT_READ_PLACEHOLDER)
.map(fragment => fragment.trim())
.filter(fragment => fragment.length > 0)
// 移除最后一个元素如果它只包含 HTML 标签而无实际文本
if (contentFragments.length > 0 && isHtmlEmpty(contentFragments[contentFragments.length - 1])) {
contentFragments.pop()
}
return contentFragments
}
/**
* 获取受限阅读类型名称
* @param post 文章对象
* @returns 类型名称(密码/验证码/登录/付费/评论)
*/
export function getRestrictReadTypeName(post: IPost): string {
const annotations = post?.metadata?.annotations
const restrictReadEnable = annotations?.restrictReadEnable
if (restrictReadEnable === 'false' || !restrictReadEnable)
return ''
if (restrictReadEnable === 'password')
return '密码'
if (restrictReadEnable === 'code')
return '验证码'
if (restrictReadEnable === 'login')
return '登录'
if (restrictReadEnable === 'pay')
return '付费'
if (restrictReadEnable === 'comment')
return '评论'
return ''
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 统一弹窗工具(源自旧项目 js_sdk/fy-showModal,改 utils 按需导出)
* 新架构简单场景直接用 uni.showModal + Promise 包装;需要统一视觉时用 wot-ui wd-dialog/wd-message-box
* 替代旧项目的 uni.$eShowModal 全局挂载
*/
import { isWechat } from './platform'
export interface IShowModalOptions {
title?: string
content?: string
showCancel?: boolean
cancelText?: string
cancelColor?: string
confirmText?: string
confirmColor?: string
editable?: boolean
placeholderText?: string
}
export interface IShowModalResult {
confirm: boolean
cancel: boolean
content?: string
}
/**
* 统一弹窗(Promise 化 uni.showModal)
* @param options 弹窗配置
* @returns Promise<IShowModalResult>
*/
export function eShowModal(options: IShowModalOptions): Promise<IShowModalResult> {
return new Promise((resolve) => {
const {
title = '提示',
content = '',
showCancel = false,
cancelText = '取消',
cancelColor = '#999999',
confirmText = '确定',
confirmColor = '#03a9f4',
editable = false,
placeholderText = '',
} = options
uni.showModal({
title,
content,
showCancel,
cancelText,
cancelColor,
confirmText,
confirmColor,
editable,
placeholderText,
success: (res) => {
resolve({
confirm: res.confirm,
cancel: res.cancel,
content: res.content || '',
})
},
fail: () => {
resolve({ confirm: false, cancel: true })
},
})
})
}
/**
* 确认弹窗(快捷方法)
* @param content 提示内容
* @param title 标题
*/
export async function confirmModal(content: string, title = '提示'): Promise<boolean> {
const res = await eShowModal({
title,
content,
showCancel: true,
cancelText: '取消',
confirmText: '确定',
})
return res.confirm
}
/**
* 复制文本到剪贴板
* @param content 待复制内容
* @param tips 复制成功提示
*/
export function copyText(content: string, tips = '内容已复制成功!') {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
/** 平台判断(微信小程序导出,供条件编译使用) */
export { isWechat }
+55
View File
@@ -0,0 +1,55 @@
/**
* 通用缓存(带过期时间)
* 源自旧项目 utils/storage.js,按需命名导出
*/
interface ICacheItem<T> {
data: T
/** 存储时间戳(秒) */
time: number
/** 过期时间(秒),0 表示永久有效 */
expire: number
}
/**
* 设置缓存
* @param key 缓存 key
* @param value 存储值
* @param expire 过期时间(秒),默认 0 永久有效
*/
export function setCache<T>(key: string, value: T, expire = 0): void {
const obj: ICacheItem<T> = {
data: value,
time: Date.now() / 1000,
expire,
}
uni.setStorageSync(key, JSON.stringify(obj))
}
/**
* 获取缓存,过期自动清除并返回 null
*/
export function getCache<T>(key: string): T | null {
const val = uni.getStorageSync(key)
if (!val)
return null
let item: ICacheItem<T>
try {
item = JSON.parse(val)
}
catch {
return null
}
if (item.expire && Date.now() / 1000 - item.time > item.expire) {
uni.removeStorageSync(key)
return null
}
return item.data
}
/**
* 删除缓存
*/
export function delCache(key: string): void {
uni.removeStorageSync(key)
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 节流工具(源自旧项目 utils/throttle.js,按需命名导出)
* 首次立即执行,之后在 wait 时间窗口内最多执行一次
*/
let timer: ReturnType<typeof setTimeout> | null = null
let flag = false
/**
* 节流函数
* @param fn 待执行函数
* @param wait 节流间隔(毫秒),默认 1000
*/
export function throttle(fn: (...args: unknown[]) => void, wait = 1000) {
return function (this: unknown, ...args: unknown[]) {
if (flag)
return
flag = true
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
flag = false
fn.apply(this, args)
}, wait)
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* 点赞状态工具(源自旧项目 utils/upvote.js,按需命名导出)
* 文章/瞬间点赞状态本地缓存
*/
import { getCache, setCache } from './storage'
const upvote = {
key: 'upvote_records',
maxLength: 300,
}
/** 获取已点赞记录列表 */
function getRecords(): string[] {
return getCache<string[]>(upvote.key) || []
}
/** 保存点赞记录列表 */
function setRecords(list: string[]) {
setCache(upvote.key, list)
}
/**
* 是否已点赞
* @param key 点赞目标标识(如 post name)
*/
export function hasUpvoted(key: string): boolean {
return getRecords().includes(key)
}
/**
* 记录点赞(超过最大长度时清空旧记录)
* @param key 点赞目标标识
*/
export function addUpvoteRecord(key: string) {
const list = getRecords()
if (list.length >= upvote.maxLength) {
// 记录过多时重置
setRecords([key])
return
}
if (!list.includes(key)) {
list.push(key)
setRecords(list)
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* URL 处理工具(源自旧项目 utils/index.js 的 URL 相关方法,按需命名导出)
* 依赖 BASE_API(env)与应用图片配置(storage)
*/
import { getCache } from './storage'
import type { IAppConfig } from '@/api/types/uni-halo'
/** 应用配置存储 key(与 store/appConfig 保持一致) */
const APP_GLOBAL_CONFIGS_KEY = 'APP_GLOBAL_CONFIGS'
/** 基础请求地址(env) */
const BASE_API = import.meta.env.VITE_SERVER_BASEURL || ''
/** 读取应用配置(store 未就绪时兜底从 storage 解析) */
function getAppConfig(): Partial<IAppConfig> {
return getCache<IAppConfig>(APP_GLOBAL_CONFIGS_KEY) || {}
}
/**
* 检查是否为 http/https 链接
*/
export function checkIsUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
/**
* 检查链接:相对路径补全为完整地址
* @param url 原始链接
*/
export function checkUrl(url?: string): string {
if (!url)
return ''
if (checkIsUrl(url))
return url
return BASE_API + url
}
/**
* 检查封面图:无封面时使用默认封面,并追加版本参数避免缓存
* @param thumbnail 封面图
* @param mustRealUrl 是否必须返回真实地址
*/
export function checkThumbnailUrl(thumbnail?: string, mustRealUrl = false): string {
if (!thumbnail && mustRealUrl) {
return checkUrl(getAppConfig().imagesConfig?.defaultStaticThumbnailUrl)
}
let fallback = checkUrl(getAppConfig().imagesConfig?.defaultThumbnailUrl)
fallback = appendNextVersion(fallback)
if (!thumbnail)
return fallback
if (!checkIsUrl(thumbnail))
return BASE_API + thumbnail
return thumbnail
}
/**
* 检查图片:无图片时使用默认图,并追加版本参数
*/
export function checkImageUrl(image?: string): string {
let fallback = checkUrl(getAppConfig().imagesConfig?.defaultImageUrl)
fallback = appendNextVersion(fallback)
if (!image)
return fallback
if (!checkIsUrl(image))
return BASE_API + image
return image
}
/**
* 检查头像:无头像时使用默认头像,并追加版本参数
*/
export function checkAvatarUrl(avatar?: string): string {
if (!avatar) {
return appendNextVersion(checkUrl(getAppConfig().imagesConfig?.defaultAvatarUrl))
}
if (!checkIsUrl(avatar))
return BASE_API + avatar
return avatar
}
/** 追加版本参数(?next-v=时间戳),避免图片缓存 */
function appendNextVersion(url: string): string {
if (!url)
return ''
if (!url.includes('?')) {
return `${url}?next-v=${Date.now()}`
}
return `${url}&next-v=${Date.now()}`
}
+55
View File
@@ -0,0 +1,55 @@
/**
* URL 参数工具(源自旧项目 utils/url.params.js,按需命名导出)
*/
/**
* 对象转换为 url 参数形式
* @param param 将要转换为 URL 参数的字符串对象
* @param key URL 参数字符串的前缀
* @param encode 是否进行 URL 编码,默认 true
*/
export function jsonToUrlParams(param: unknown, key?: string, encode?: boolean): string {
if (param == null)
return ''
let paramStr = ''
const t = typeof param
if (t === 'string' || t === 'number' || t === 'boolean') {
paramStr += `&${key}=${encode == null || encode ? encodeURIComponent(String(param)) : String(param)}`
}
else {
for (const i in param as Record<string, unknown>) {
const k = key == null ? i : key + (Array.isArray(param) ? `[${i}]` : `.${i}`)
paramStr += jsonToUrlParams((param as Record<string, unknown>)[i], k, encode)
}
}
return paramStr
}
/** 过滤空值数组 */
function cleanArray(actual: string[]): string[] {
return actual.filter(Boolean)
}
/**
* json 对象转 Url 参数
* @param json 参数对象
*/
export function jsonToUrlParams2(json: Record<string, unknown> | null | undefined): string {
if (!json)
return ''
return cleanArray(
Object.keys(json).map((key) => {
if (json[key] === undefined)
return ''
return `${encodeURIComponent(key)}=${encodeURIComponent(String(json[key]))}`
}),
).join('&')
}
/**
* 仅获取域名
* @param url 完整地址
*/
export function getDomainOnly(url: string): string {
return url.replace(/^(https?:\/\/)/, '').split('/')[0]
}
+20
View File
@@ -0,0 +1,20 @@
/**
* UUID 生成工具(源自旧项目 utils/uuid.js,按需命名导出)
*/
/**
* 生成 UUID(v4)
*/
export function generateUUID(): string {
// 使用 crypto.randomUUID(部分平台支持),否则降级为时间戳+随机数
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
let uuid = ''
const timestamp = Date.now()
for (let i = 0; i < 32; i++) {
const r = (timestamp + Math.random() * 16) % 16 | 0
uuid += (i === 12 ? 4 : (i === 16 ? (r & 3) | 8 : r)).toString(16)
}
return uuid
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 投票工具(源自旧项目 utils/vote.js,按需命名导出)
* 投票状态计算、UID 缓存、投票周期判断
*/
import { getCache, setCache } from './storage'
/** 投票 UID 缓存 key */
const UnihaloVoteUid = 'unihalo_vote_uid'
export type VoteType = 'SINGLE' | 'MULTIPLE'
export type VoteState = 'not-voted' | 'voting' | 'voted' | 'vote-ended'
/** 投票类型常量 */
export const VOTE_TYPES: { SINGLE: VoteType, MULTIPLE: VoteType } = {
SINGLE: 'SINGLE',
MULTIPLE: 'MULTIPLE',
}
/** 投票状态常量 */
export const VOTE_STATES: { NOT_VOTED: VoteState, VOTING: VoteState, VOTED: VoteState, VOTE_ENDED: VoteState } = {
NOT_VOTED: 'not-voted',
VOTING: 'voting',
VOTED: 'voted',
VOTE_ENDED: 'vote-ended',
}
/**
* 获取投票 UID(不存在则生成)
*/
export function getOrCreateVoteUid(): string {
let uid = getCache<string>(UnihaloVoteUid)
if (!uid) {
uid = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
setCache(UnihaloVoteUid, uid)
}
return uid
}
/**
* 计算投票状态
* @param vote 投票对象(含 startTime/endTime/options)
* @param voteTypes 已投票项
* @param canAnonymously 是否允许匿名
*/
export function calcVoteState(
vote: { startTime?: string, endTime?: string, [key: string]: unknown },
voteTypes: string[],
canAnonymously: boolean,
): VoteState {
const now = Date.now()
const startTime = vote.startTime ? new Date(vote.startTime).getTime() : now
const endTime = vote.endTime ? new Date(vote.endTime).getTime() : now
if (endTime < now)
return VOTE_STATES.VOTE_ENDED
if (startTime > now)
return VOTE_STATES.NOT_VOTED
if (voteTypes.length !== 0)
return VOTE_STATES.VOTED
if (!canAnonymously)
return VOTE_STATES.NOT_VOTED
return VOTE_STATES.VOTING
}
/**
* 计算选项票数占比
* @param vote 投票对象(含 stats.voteCount)
* @param option 选项(含 count)
*/
export function calcVotePercent(vote: { stats?: { voteCount?: number } }, option: { count?: number }): number {
const total = vote.stats?.voteCount || 0
const count = option.count || 0
if (total === 0)
return 0
return Number(((count / total) * 100).toFixed(2))
}
/** 投票缓存 key 前缀 */
const VOTE_CACHE_KEY = 'unihalo_vote_'
interface IVoteCacheData {
selected: string[]
data: unknown
}
/**
* 投票缓存工具(源自旧项目 utils/vote.js 的 voteCacheUtil)
*/
export const voteCacheUtil = {
/** 是否已缓存(已投票) */
has(name: string): boolean {
return !!getCache<IVoteCacheData>(VOTE_CACHE_KEY + name)
},
/** 获取缓存数据 */
get(name: string): IVoteCacheData | null {
return getCache<IVoteCacheData>(VOTE_CACHE_KEY + name) || null
},
/** 写入缓存 */
set(name: string, data: IVoteCacheData): void {
setCache(VOTE_CACHE_KEY + name, data)
},
}