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:
@@ -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 ''
|
||||
}
|
||||
}
|
||||
@@ -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 || '检查更新失败' }
|
||||
}
|
||||
}
|
||||
@@ -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] : ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user