1
0
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:
小莫唐尼
2026-09-03 22:36:26 +08:00
parent cf9c02cfc3
commit 902faa70e7
49 changed files with 3170 additions and 4288 deletions
+3 -2
View File
@@ -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,
})
+193
View File
@@ -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('updateLocalPrefsnull 删除该键(回退跟随站点默认)', () => {
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('collectSiteDefaultsbanner 站点默认映射(showIndicator→useDot)', () => {
const site = collectSiteDefaults({
pageConfig: {
homeConfig: {
bannerConfig: { showIndicator: false, dotPosition: 'bottom' },
},
},
})
expect(site.banner).toEqual({ useDot: false, dotPosition: 'bottom' })
})
it('collectSiteDefaultspreferences(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)
})
})
+221
View File
@@ -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
}