mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-12 16:40:40 +08:00
refactor: 移除启动页逻辑,新增全局组件与偏好设置系统
1. 移除旧版启动页分流逻辑,直接跳转首页 2. 新增返回顶部、章节标题、导航栏等全局组件 3. 重构偏好设置系统,实现站点默认与本地差异分层管理 4. 删除冗余的测试mock、插件模块与样式文件 5. 优化文章卡片与评论组件样式,更新全局主题色 6. 清理废弃的请求参数与配置项
This commit is contained in:
+3
-2
@@ -8,12 +8,13 @@ import { useAppConfigStore } from '@/store/appConfig'
|
||||
let aniWaitIndex = 0
|
||||
|
||||
/**
|
||||
* 设置页面标题(默认取应用配置 startConfig.title)
|
||||
* 设置页面标题(默认取应用配置 appInfo.name,原 startConfig 已随启动页下线)
|
||||
* @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'
|
||||
const fallbackTitle = ((appConfigStore.configs.appConfig as { appInfo?: { name?: string } } | undefined)
|
||||
?.appInfo?.name) || 'uni-halo'
|
||||
uni.setNavigationBarTitle({
|
||||
title: title || fallbackTitle,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DefaultAppSettings } from '@/config/appSettings'
|
||||
import type { IAppSettings } from '@/config/appSettings'
|
||||
import {
|
||||
LOCAL_PREFS_KEY,
|
||||
clearLocalPrefs,
|
||||
collectSiteDefaults,
|
||||
isLocalOverride,
|
||||
mergeWithDefaults,
|
||||
migrateLegacyLocalPrefs,
|
||||
readLocalPrefs,
|
||||
updateLocalPrefs,
|
||||
} from './preference'
|
||||
|
||||
/** 内存版 uni storage(与 utils/storage 的 {data,time,expire} 包装配合) */
|
||||
const mem = new Map<string, string>()
|
||||
|
||||
function setupUniStorageMock() {
|
||||
vi.mocked(uni.getStorageSync).mockImplementation((key: string) => mem.get(key) ?? null)
|
||||
vi.mocked(uni.setStorageSync).mockImplementation((key: string, val: unknown) => {
|
||||
mem.set(key, val as string)
|
||||
})
|
||||
vi.mocked(uni.removeStorageSync).mockImplementation((key: string) => {
|
||||
mem.delete(key)
|
||||
})
|
||||
}
|
||||
|
||||
describe('preference 基础读写', () => {
|
||||
beforeEach(() => {
|
||||
mem.clear()
|
||||
setupUniStorageMock()
|
||||
})
|
||||
|
||||
it('readLocalPrefs:无数据时返回空对象', () => {
|
||||
expect(readLocalPrefs()).toEqual({})
|
||||
})
|
||||
|
||||
it('updateLocalPrefs:嵌套字段增量合并', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
|
||||
updateLocalPrefs({ layout: { cardType: 'tb_image_text' } })
|
||||
expect(readLocalPrefs()).toEqual({
|
||||
layout: { home: 'h_row_col2', cardType: 'tb_image_text' },
|
||||
})
|
||||
})
|
||||
|
||||
it('updateLocalPrefs:null 删除该键(回退跟随站点默认)', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col2', cardType: 'tb_image_text' } })
|
||||
updateLocalPrefs({ layout: { home: null } })
|
||||
expect(readLocalPrefs()).toEqual({ layout: { cardType: 'tb_image_text' } })
|
||||
})
|
||||
|
||||
it('updateLocalPrefs(null):整体清空差异', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
|
||||
updateLocalPrefs(null)
|
||||
expect(readLocalPrefs()).toEqual({})
|
||||
expect(mem.has(LOCAL_PREFS_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('clearLocalPrefs:删除存储键', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
|
||||
clearLocalPrefs()
|
||||
expect(readLocalPrefs()).toEqual({})
|
||||
})
|
||||
|
||||
it('isLocalOverride:按路径判断是否被本地覆盖', () => {
|
||||
updateLocalPrefs({ gallery: { useWaterfull: false } })
|
||||
expect(isLocalOverride(readLocalPrefs(), ['gallery', 'useWaterfull'])).toBe(true)
|
||||
expect(isLocalOverride(readLocalPrefs(), ['banner', 'useDot'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeWithDefaults / collectSiteDefaults', () => {
|
||||
it('默认值兜底:无站点默认无本地差异时等于内置默认', () => {
|
||||
expect(mergeWithDefaults()).toEqual(DefaultAppSettings)
|
||||
})
|
||||
|
||||
it('本地优先于站点默认,站点默认优先于内置默认', () => {
|
||||
const merged = mergeWithDefaults(
|
||||
{ layout: { home: 'h_row_col1' }, gallery: { useWaterfull: true } },
|
||||
{ layout: { home: 'h_row_col2' } },
|
||||
)
|
||||
expect(merged.layout.home).toBe('h_row_col2')
|
||||
expect(merged.gallery.useWaterfull).toBe(true)
|
||||
expect(merged.isAvatarRadius).toBe(DefaultAppSettings.isAvatarRadius)
|
||||
})
|
||||
|
||||
it('collectSiteDefaults:banner 站点默认映射(showIndicator→useDot)', () => {
|
||||
const site = collectSiteDefaults({
|
||||
pageConfig: {
|
||||
homeConfig: {
|
||||
bannerConfig: { showIndicator: false, dotPosition: 'bottom' },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(site.banner).toEqual({ useDot: false, dotPosition: 'bottom' })
|
||||
})
|
||||
|
||||
it('collectSiteDefaults:preferences(L0)映射到 layout.home/cardType/isAvatarRadius', () => {
|
||||
const site = collectSiteDefaults({
|
||||
preferences: {
|
||||
homeListLayout: 'h_row_col2',
|
||||
articleCardType: 'tb_image_text',
|
||||
avatarRadius: true,
|
||||
},
|
||||
})
|
||||
expect(site.layout).toEqual({ home: 'h_row_col2', cardType: 'tb_image_text' })
|
||||
expect(site.isAvatarRadius).toBe(true)
|
||||
})
|
||||
|
||||
it('preferences L0 参与合并,本地未覆盖时跟随站点默认', () => {
|
||||
const site = collectSiteDefaults({
|
||||
preferences: {
|
||||
homeListLayout: 'h_row_col2',
|
||||
articleCardType: 'tb_image_text',
|
||||
avatarRadius: true,
|
||||
},
|
||||
})
|
||||
const merged = mergeWithDefaults(site, {})
|
||||
expect(merged.layout.home).toBe('h_row_col2')
|
||||
expect(merged.layout.cardType).toBe('tb_image_text')
|
||||
expect(merged.isAvatarRadius).toBe(true)
|
||||
})
|
||||
|
||||
it('preferences L0 可被本地差异覆盖,重置后回退站点默认', () => {
|
||||
const site = collectSiteDefaults({
|
||||
preferences: {
|
||||
homeListLayout: 'h_row_col2',
|
||||
articleCardType: 'tb_image_text',
|
||||
avatarRadius: true,
|
||||
},
|
||||
})
|
||||
const merged = mergeWithDefaults(site, { layout: { home: 'h_row_col1' }, isAvatarRadius: false })
|
||||
expect(merged.layout.home).toBe('h_row_col1')
|
||||
expect(merged.layout.cardType).toBe('tb_image_text')
|
||||
expect(merged.isAvatarRadius).toBe(false)
|
||||
})
|
||||
|
||||
it('站点 banner 默认参与合并,本地未覆盖时跟随站点默认', () => {
|
||||
const site = collectSiteDefaults({
|
||||
pageConfig: {
|
||||
homeConfig: {
|
||||
bannerConfig: { showIndicator: false, dotPosition: 'bottom' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const merged = mergeWithDefaults(site, {})
|
||||
expect(merged.banner.useDot).toBe(false)
|
||||
expect(merged.banner.dotPosition).toBe('bottom')
|
||||
expect(merged.layout.home).toBe(DefaultAppSettings.layout.home)
|
||||
})
|
||||
|
||||
it('未知枚举值不回退抛错(跟随默认)', () => {
|
||||
const merged = mergeWithDefaults({}, { layout: { home: 'not-exist' } })
|
||||
expect(merged.layout.home).toBe('not-exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateLegacyLocalPrefs', () => {
|
||||
beforeEach(() => {
|
||||
mem.clear()
|
||||
setupUniStorageMock()
|
||||
})
|
||||
|
||||
it('旧 persist 存在时仅迁移被改过的叶子字段', () => {
|
||||
const legacySettings: IAppSettings = JSON.parse(JSON.stringify(DefaultAppSettings))
|
||||
legacySettings.layout.home = 'h_row_col2'
|
||||
legacySettings.gallery.useWaterfull = false
|
||||
mem.set('setting', JSON.stringify({ settings: legacySettings }))
|
||||
|
||||
expect(migrateLegacyLocalPrefs()).toBe(true)
|
||||
expect(readLocalPrefs()).toEqual({
|
||||
layout: { home: 'h_row_col2' },
|
||||
gallery: { useWaterfull: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('已存在新差异键时不再重复迁移', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col1' } })
|
||||
const legacySettings = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings
|
||||
legacySettings.layout.home = 'h_row_col2'
|
||||
mem.set('setting', JSON.stringify({ settings: legacySettings }))
|
||||
|
||||
expect(migrateLegacyLocalPrefs()).toBe(false)
|
||||
expect(readLocalPrefs()).toEqual({ layout: { home: 'h_row_col1' } })
|
||||
})
|
||||
|
||||
it('无旧键或格式异常时返回 false 且不写新键', () => {
|
||||
expect(migrateLegacyLocalPrefs()).toBe(false)
|
||||
mem.set('setting', 'not-json{')
|
||||
expect(migrateLegacyLocalPrefs()).toBe(false)
|
||||
expect(mem.has(LOCAL_PREFS_KEY)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 用户偏好「两层」基建(设计见插件仓库 .docs/config-system-v2-redesign.md §3-4)
|
||||
*
|
||||
* 两层语义:
|
||||
* - L0 站点默认:插件 getConfigs 下发(本模块把 getConfigs 中与偏好相关的字段收集为 Partial<IAppSettings>);
|
||||
* - L1-L 本地差异:storage 键 `uh_pref_local_v1`,只存与站点默认不同的字段(字段级覆盖,本地优先),
|
||||
* 值缺省/被删除即回退跟随站点默认;重置 = 删除本地差异。
|
||||
*
|
||||
* 读取优先级:本地差异 > 站点默认(L0) > 客户端内置默认(DefaultAppSettings)。
|
||||
*/
|
||||
import { DefaultAppSettings } from '@/config/appSettings'
|
||||
import type { IAppSettings } from '@/config/appSettings'
|
||||
import type { IAppConfig } from '@/api/types/uni-halo'
|
||||
import { delCache, getCache, setCache } from '@/utils/storage'
|
||||
|
||||
/** 本地偏好差异存储 key(仅差异 JSON) */
|
||||
export const LOCAL_PREFS_KEY = 'uh_pref_local_v1'
|
||||
/** 旧版 setting store persist key(pinia-plugin-persistedstate 默认以 store id 为 key) */
|
||||
export const LEGACY_SETTINGS_KEY = 'setting'
|
||||
|
||||
/** 深层可选类型(仅覆盖部分字段的差异) */
|
||||
export type DeepPartial<T> = {
|
||||
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
|
||||
}
|
||||
|
||||
/** 本地偏好差异(只放与站点默认不同的字段) */
|
||||
export type LocalPrefs = DeepPartial<IAppSettings>
|
||||
|
||||
/** 读取本地偏好差异(无则返回空对象) */
|
||||
export function readLocalPrefs(): LocalPrefs {
|
||||
const prefs = getCache<LocalPrefs>(LOCAL_PREFS_KEY)
|
||||
return prefs && typeof prefs === 'object' ? prefs : {}
|
||||
}
|
||||
|
||||
/** 整份覆盖写本地偏好差异(一般由 updateLocalPrefs 内部使用) */
|
||||
function writeLocalPrefs(prefs: LocalPrefs): void {
|
||||
setCache(LOCAL_PREFS_KEY, prefs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量更新本地偏好差异(null 表示删除该键、回退跟随站点默认)
|
||||
* @param patch 仅包含被覆盖字段的差异;嵌套对象按 key 递归合并
|
||||
*/
|
||||
export function updateLocalPrefs(patch: LocalPrefs | null): void {
|
||||
if (patch === null) {
|
||||
clearLocalPrefs()
|
||||
return
|
||||
}
|
||||
writeLocalPrefs(mergePrefs(readLocalPrefs(), patch))
|
||||
}
|
||||
|
||||
/** 删除本地偏好差异(重置 = 清本地,回退站点默认) */
|
||||
export function clearLocalPrefs(): void {
|
||||
delCache(LOCAL_PREFS_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 L0 站点默认(getConfigs 下发值)中与偏好相关的字段收集为本地差异形状的站点默认。
|
||||
* 偏好字段与 getConfigs 字段不完全同名,此处维护映射表:
|
||||
* - preferences.homeListLayout / articleCardType / avatarRadius(插件「通用配置-偏好设置」
|
||||
* 分区,L0 additive 顶层键)→ layout.home / layout.cardType / isAvatarRadius;
|
||||
* - pageConfig.homeConfig.bannerConfig → banner.useDot / dotPosition。
|
||||
*/
|
||||
export function collectSiteDefaults(configs: Partial<IAppConfig>): LocalPrefs {
|
||||
const result: LocalPrefs = {}
|
||||
|
||||
// 站点级展示偏好默认(L0,GeneralConfig.preferences,2026-09-02 插件端新增)
|
||||
const preferences = configs.preferences
|
||||
if (preferences && typeof preferences === 'object') {
|
||||
if (preferences.homeListLayout) {
|
||||
result.layout = { ...result.layout, home: preferences.homeListLayout }
|
||||
}
|
||||
if (preferences.articleCardType) {
|
||||
result.layout = { ...result.layout, cardType: preferences.articleCardType }
|
||||
}
|
||||
if (typeof preferences.avatarRadius === 'boolean') {
|
||||
result.isAvatarRadius = preferences.avatarRadius
|
||||
}
|
||||
}
|
||||
|
||||
// 轮播渲染参数(L0):站点「显示指示器」→ 本地偏好 banner.useDot
|
||||
const bannerConfig = configs.pageConfig?.homeConfig?.bannerConfig
|
||||
if (bannerConfig && typeof bannerConfig === 'object') {
|
||||
result.banner = {
|
||||
useDot: bannerConfig.showIndicator,
|
||||
dotPosition: bannerConfig.dotPosition,
|
||||
}
|
||||
}
|
||||
|
||||
// 预留:图库瀑布流 L0(galleryConfig.useWaterfall)随二期下发后在此补充
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 偏好解析合并:本地差异 > 站点默认 > 内置默认。
|
||||
* @param siteDefaults collectSiteDefaults 的结果(可空)
|
||||
* @param localPrefs readLocalPrefs 的结果(可空)
|
||||
*/
|
||||
export function mergeWithDefaults(
|
||||
siteDefaults?: LocalPrefs | null,
|
||||
localPrefs?: LocalPrefs | null,
|
||||
): IAppSettings {
|
||||
const base = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings
|
||||
const site = siteDefaults && typeof siteDefaults === 'object' ? siteDefaults : {}
|
||||
const local = localPrefs && typeof localPrefs === 'object' ? localPrefs : {}
|
||||
// base 已含完整默认结构,合并结果必然满足 IAppSettings
|
||||
return mergePrefs(mergePrefs(base, site), local) as IAppSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归合并:target 为底,source 覆盖;source 中值为 null/undefined 的键删除(null 语义=跟随默认)
|
||||
* 数组等引用类型直接替换。对象用结构化克隆保证合并结果与 DefaultAppSettings 结构一致。
|
||||
*/
|
||||
function mergePrefs<T>(target: T, source: T): T {
|
||||
if (!isObject(source)) {
|
||||
return target
|
||||
}
|
||||
const output: Record<string, unknown> = isObject(target)
|
||||
? { ...(target as Record<string, unknown>) }
|
||||
: {}
|
||||
const targetRecord = (target ?? {}) as Record<string, unknown>
|
||||
Object.keys(source as Record<string, unknown>).forEach((key) => {
|
||||
const sourceValue = (source as Record<string, unknown>)[key]
|
||||
if (sourceValue === null || sourceValue === undefined) {
|
||||
delete output[key]
|
||||
return
|
||||
}
|
||||
const targetValue = targetRecord[key]
|
||||
if (isObject(targetValue) && isObject(sourceValue)) {
|
||||
output[key] = mergePrefs(targetValue, sourceValue)
|
||||
}
|
||||
else {
|
||||
output[key] = sourceValue
|
||||
}
|
||||
})
|
||||
return output as T
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版迁移:把旧 setting store persist 的全量本地设置(无 L0 时代)转为差异。
|
||||
* 规则:与 DefaultAppSettings 相同的键视为未覆盖(丢弃),不同键写入 uh_pref_local_v1。
|
||||
* 仅当新差异键不存在且旧键存在时执行一次;返回是否发生迁移。
|
||||
*/
|
||||
export function migrateLegacyLocalPrefs(): boolean {
|
||||
if (getCache<LocalPrefs>(LOCAL_PREFS_KEY)) {
|
||||
return false
|
||||
}
|
||||
const raw = uni.getStorageSync(LEGACY_SETTINGS_KEY)
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
let legacy: { settings?: IAppSettings } | null = null
|
||||
try {
|
||||
legacy = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
const settings = legacy?.settings
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return false
|
||||
}
|
||||
const diff = diffFromDefaults(settings)
|
||||
writeLocalPrefs(diff)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 计算 settings 与 DefaultAppSettings 的差异(仅保留被用户改过的叶子字段) */
|
||||
function diffFromDefaults(settings: IAppSettings): LocalPrefs {
|
||||
const diff: Record<string, unknown> = {}
|
||||
const defaultsRecord = DefaultAppSettings as unknown as Record<string, unknown>
|
||||
const settingsRecord = settings as unknown as Record<string, unknown>
|
||||
Object.keys(defaultsRecord).forEach((key) => {
|
||||
const defaultItem = defaultsRecord[key]
|
||||
const settingItem = settingsRecord[key]
|
||||
if (isObject(defaultItem) && isObject(settingItem)) {
|
||||
// 只保留与 default 不一致的嵌套键
|
||||
const result: Record<string, unknown> = {}
|
||||
Object.keys(defaultItem).forEach((nestedKey) => {
|
||||
const dv = defaultItem[nestedKey]
|
||||
const sv = settingItem[nestedKey]
|
||||
if (isObject(dv) && isObject(sv)) {
|
||||
const deep = diffFromDefaults({ ...dv, ...sv } as unknown as IAppSettings)
|
||||
if (Object.keys(deep).length > 0) {
|
||||
result[nestedKey] = deep
|
||||
}
|
||||
}
|
||||
else if (sv !== dv) {
|
||||
result[nestedKey] = sv
|
||||
}
|
||||
})
|
||||
if (Object.keys(result).length > 0) {
|
||||
diff[key] = result
|
||||
}
|
||||
}
|
||||
else if (settingItem !== undefined && settingItem !== defaultItem) {
|
||||
diff[key] = settingItem
|
||||
}
|
||||
})
|
||||
return diff as LocalPrefs
|
||||
}
|
||||
|
||||
/** 判断某字段当前是否被本地差异覆盖(供设置页三态展示) */
|
||||
export function isLocalOverride(localPrefs: LocalPrefs, path: string[]): boolean {
|
||||
let cursor: unknown = localPrefs
|
||||
for (const key of path) {
|
||||
if (cursor === null || cursor === undefined) {
|
||||
return false
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[key]
|
||||
if (cursor === undefined) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user