mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-12 16:40:40 +08:00
feat: 实现全站布局偏好系统,支持多页面独立配置
本次提交完成了完整的偏好设置体系重构: 1. 新增preferences.md文档说明布局偏好设计与映射规则 2. 重构布局配置结构,按页面分组管理列表布局与卡片样式 3. 实现后端preferences字段到本地配置的自动映射转换 4. 完成首页、文章列表、归档页的布局动态切换逻辑 5. 重构投票组件样式绑定,改用unocss类名实现样式分离 6. 新增偏好设置页面,支持按页面配置布局参数 7. 兼容旧版布局参数的自动归一化转换 8. 补充完整的单元测试用例覆盖新旧数据迁移场景
This commit is contained in:
@@ -36,28 +36,28 @@ describe('preference 基础读写', () => {
|
||||
})
|
||||
|
||||
it('updateLocalPrefs:嵌套字段增量合并', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
|
||||
updateLocalPrefs({ layout: { cardType: 'tb_image_text' } })
|
||||
updateLocalPrefs({ layout: { home: { listLayout: 'double' } } })
|
||||
updateLocalPrefs({ layout: { home: { cardType: 'image_bottom' } } })
|
||||
expect(readLocalPrefs()).toEqual({
|
||||
layout: { home: 'h_row_col2', cardType: 'tb_image_text' },
|
||||
layout: { home: { listLayout: 'double', cardType: 'image_bottom' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('updateLocalPrefs:null 删除该键(回退跟随站点默认)', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col2', cardType: 'tb_image_text' } })
|
||||
updateLocalPrefs({ layout: { home: { listLayout: 'double', cardType: 'image_bottom' } } })
|
||||
updateLocalPrefs({ layout: { home: null } })
|
||||
expect(readLocalPrefs()).toEqual({ layout: { cardType: 'tb_image_text' } })
|
||||
expect(readLocalPrefs()).toEqual({ layout: {} })
|
||||
})
|
||||
|
||||
it('updateLocalPrefs(null):整体清空差异', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
|
||||
updateLocalPrefs({ layout: { home: { listLayout: 'double' } } })
|
||||
updateLocalPrefs(null)
|
||||
expect(readLocalPrefs()).toEqual({})
|
||||
expect(mem.has(LOCAL_PREFS_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('clearLocalPrefs:删除存储键', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
|
||||
updateLocalPrefs({ layout: { home: { listLayout: 'double' } } })
|
||||
clearLocalPrefs()
|
||||
expect(readLocalPrefs()).toEqual({})
|
||||
})
|
||||
@@ -76,10 +76,10 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
||||
|
||||
it('本地优先于站点默认,站点默认优先于内置默认', () => {
|
||||
const merged = mergeWithDefaults(
|
||||
{ layout: { home: 'h_row_col1' }, gallery: { useWaterfull: true } },
|
||||
{ layout: { home: 'h_row_col2' } },
|
||||
{ layout: { home: { listLayout: 'single' } }, gallery: { useWaterfull: true } },
|
||||
{ layout: { home: { listLayout: 'double' } } },
|
||||
)
|
||||
expect(merged.layout.home).toBe('h_row_col2')
|
||||
expect(merged.layout.home.listLayout).toBe('double')
|
||||
expect(merged.gallery.useWaterfull).toBe(true)
|
||||
expect(merged.isAvatarRadius).toBe(DefaultAppSettings.isAvatarRadius)
|
||||
})
|
||||
@@ -95,15 +95,22 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
||||
expect(site.banner).toEqual({ useDot: false, dotPosition: 'bottom' })
|
||||
})
|
||||
|
||||
it('collectSiteDefaults:preferences(L0)映射到 layout.home/cardType/isAvatarRadius', () => {
|
||||
it('collectSiteDefaults:preferences(L0)映射到 layout 页面分组/isAvatarRadius', () => {
|
||||
const site = collectSiteDefaults({
|
||||
preferences: {
|
||||
homeListLayout: 'h_row_col2',
|
||||
articleCardType: 'tb_image_text',
|
||||
homeCardType: 'image_bottom',
|
||||
articlesListLayout: 'single',
|
||||
articleCardType: 'image_left',
|
||||
archivesCardType: 'image_top',
|
||||
avatarRadius: true,
|
||||
},
|
||||
})
|
||||
expect(site.layout).toEqual({ home: 'h_row_col2', cardType: 'tb_image_text' })
|
||||
expect(site.layout).toEqual({
|
||||
home: { listLayout: 'double', cardType: 'image_bottom' },
|
||||
articles: { listLayout: 'single', cardType: 'image_left' },
|
||||
archives: { cardType: 'image_top' },
|
||||
})
|
||||
expect(site.isAvatarRadius).toBe(true)
|
||||
})
|
||||
|
||||
@@ -111,13 +118,13 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
||||
const site = collectSiteDefaults({
|
||||
preferences: {
|
||||
homeListLayout: 'h_row_col2',
|
||||
articleCardType: 'tb_image_text',
|
||||
articleCardType: 'image_bottom',
|
||||
avatarRadius: true,
|
||||
},
|
||||
})
|
||||
const merged = mergeWithDefaults(site, {})
|
||||
expect(merged.layout.home).toBe('h_row_col2')
|
||||
expect(merged.layout.cardType).toBe('tb_image_text')
|
||||
expect(merged.layout.home.listLayout).toBe('double')
|
||||
expect(merged.layout.articles.cardType).toBe('image_bottom')
|
||||
expect(merged.isAvatarRadius).toBe(true)
|
||||
})
|
||||
|
||||
@@ -125,13 +132,13 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
||||
const site = collectSiteDefaults({
|
||||
preferences: {
|
||||
homeListLayout: 'h_row_col2',
|
||||
articleCardType: 'tb_image_text',
|
||||
articleCardType: 'image_bottom',
|
||||
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')
|
||||
const merged = mergeWithDefaults(site, { layout: { home: { listLayout: 'single' } }, isAvatarRadius: false })
|
||||
expect(merged.layout.home.listLayout).toBe('single')
|
||||
expect(merged.layout.articles.cardType).toBe('image_bottom')
|
||||
expect(merged.isAvatarRadius).toBe(false)
|
||||
})
|
||||
|
||||
@@ -146,12 +153,12 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
||||
const merged = mergeWithDefaults(site, {})
|
||||
expect(merged.banner.useDot).toBe(false)
|
||||
expect(merged.banner.dotPosition).toBe('bottom')
|
||||
expect(merged.layout.home).toBe(DefaultAppSettings.layout.home)
|
||||
expect(merged.layout.home.listLayout).toBe(DefaultAppSettings.layout.home.listLayout)
|
||||
})
|
||||
|
||||
it('未知枚举值不回退抛错(跟随默认)', () => {
|
||||
const merged = mergeWithDefaults({}, { layout: { home: 'not-exist' } })
|
||||
expect(merged.layout.home).toBe('not-exist')
|
||||
const merged = mergeWithDefaults({}, { layout: { home: { listLayout: 'not-exist' } } })
|
||||
expect(merged.layout.home.listLayout).toBe('not-exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -163,7 +170,8 @@ describe('migrateLegacyLocalPrefs', () => {
|
||||
|
||||
it('旧 persist 存在时仅迁移被改过的叶子字段', () => {
|
||||
const legacySettings: IAppSettings = JSON.parse(JSON.stringify(DefaultAppSettings))
|
||||
legacySettings.layout.home = 'h_row_col2'
|
||||
// 旧结构 layout.home 为 string(列表布局),类型上绕过新结构约束
|
||||
;(legacySettings.layout as unknown as Record<string, unknown>).home = 'h_row_col2'
|
||||
legacySettings.gallery.useWaterfull = false
|
||||
mem.set('setting', JSON.stringify({ settings: legacySettings }))
|
||||
|
||||
@@ -175,13 +183,13 @@ describe('migrateLegacyLocalPrefs', () => {
|
||||
})
|
||||
|
||||
it('已存在新差异键时不再重复迁移', () => {
|
||||
updateLocalPrefs({ layout: { home: 'h_row_col1' } })
|
||||
updateLocalPrefs({ layout: { home: { listLayout: 'single' } } })
|
||||
const legacySettings = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings
|
||||
legacySettings.layout.home = 'h_row_col2'
|
||||
;(legacySettings.layout as unknown as Record<string, unknown>).home = 'h_row_col2'
|
||||
mem.set('setting', JSON.stringify({ settings: legacySettings }))
|
||||
|
||||
expect(migrateLegacyLocalPrefs()).toBe(false)
|
||||
expect(readLocalPrefs()).toEqual({ layout: { home: 'h_row_col1' } })
|
||||
expect(readLocalPrefs()).toEqual({ layout: { home: { listLayout: 'single' } } })
|
||||
})
|
||||
|
||||
it('无旧键或格式异常时返回 false 且不写新键', () => {
|
||||
|
||||
+29
-8
@@ -57,8 +57,10 @@ export function clearLocalPrefs(): void {
|
||||
/**
|
||||
* 把 L0 站点默认(getConfigs 下发值)中与偏好相关的字段收集为本地差异形状的站点默认。
|
||||
* 偏好字段与 getConfigs 字段不完全同名,此处维护映射表:
|
||||
* - preferences.homeListLayout / articleCardType / avatarRadius(插件「通用配置-偏好设置」
|
||||
* 分区,L0 additive 顶层键)→ layout.home / layout.cardType / isAvatarRadius;
|
||||
* - preferences.homeListLayout/homeCardType/articlesListLayout/articleCardType/
|
||||
* archivesListLayout/archivesCardType(L0 additive 顶层键)→ layout.{home,articles,archives}.{listLayout,cardType};
|
||||
* cardType 值为组件 layout 值(image_top/image_right/image_bottom/image_left),与设置页选项一致;
|
||||
* - preferences.avatarRadius → isAvatarRadius;
|
||||
* - pageConfig.homeConfig.bannerConfig → banner.useDot / dotPosition。
|
||||
*/
|
||||
export function collectSiteDefaults(configs: Partial<IAppConfig>): LocalPrefs {
|
||||
@@ -67,14 +69,33 @@ export function collectSiteDefaults(configs: Partial<IAppConfig>): 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 }
|
||||
const prefs = preferences as Record<string, unknown>
|
||||
/** 列表布局旧值归一化:h_row_col1/2 → single/double */
|
||||
const listLayoutOf = (v: unknown) => {
|
||||
if (typeof v !== 'string')
|
||||
return undefined
|
||||
return v === 'h_row_col2' ? 'double' : v === 'h_row_col1' ? 'single' : v
|
||||
}
|
||||
if (preferences.articleCardType) {
|
||||
result.layout = { ...result.layout, cardType: preferences.articleCardType }
|
||||
/** 页面布局:后端字段名(列表布局 + 卡片样式)→ 本地嵌套路径 */
|
||||
const setPage = (page: 'home' | 'articles' | 'archives', listKey: string, cardKey: string) => {
|
||||
const listValue = listLayoutOf(prefs[listKey])
|
||||
const cardValue = typeof prefs[cardKey] === 'string' ? prefs[cardKey] : undefined
|
||||
if (listValue === undefined && cardValue === undefined)
|
||||
return
|
||||
result.layout = {
|
||||
...result.layout,
|
||||
[page]: {
|
||||
...(listValue !== undefined ? { listLayout: listValue } : {}),
|
||||
...(cardValue !== undefined ? { cardType: cardValue } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (typeof preferences.avatarRadius === 'boolean') {
|
||||
result.isAvatarRadius = preferences.avatarRadius
|
||||
setPage('home', 'homeListLayout', 'homeCardType')
|
||||
setPage('articles', 'articlesListLayout', 'articleCardType')
|
||||
setPage('archives', 'archivesListLayout', 'archivesCardType')
|
||||
|
||||
if (typeof prefs.avatarRadius === 'boolean') {
|
||||
result.isAvatarRadius = prefs.avatarRadius
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -25,11 +25,11 @@ export const VOTE_STATES: { NOT_VOTED: VoteState; VOTING: VoteState; VOTED: Vote
|
||||
VOTE_ENDED: 'vote-ended'
|
||||
};
|
||||
|
||||
/** 投票展示状态(与旧项目 VOTE_STATES 一致:中文 + 颜色) */
|
||||
export const VOTE_STATE_LABELS: Record<string, { state: string; color: string }> = {
|
||||
未开始: { state: '未开始', color: 'orange' },
|
||||
进行中: { state: '进行中', color: 'green' },
|
||||
已结束: { state: '已结束', color: 'red' }
|
||||
/** 投票展示状态(与旧项目 VOTE_STATES 一致:中文 + unocss 文字/背景色类) */
|
||||
export const VOTE_STATE_LABELS: Record<string, { state: string; color: string; bgColor: string }> = {
|
||||
未开始: { state: '未开始', color: 'text-orange-400', bgColor: 'bg-orange-100' },
|
||||
进行中: { state: '进行中', color: 'text-green-400', bgColor: 'bg-green-100' },
|
||||
已结束: { state: '已结束', color: 'text-red-400', bgColor: 'bg-red-100' }
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -48,11 +48,12 @@ export function getOrCreateVoteUid(): string {
|
||||
* 计算投票展示状态(与旧项目 calcVoteState 一致)
|
||||
* 非 custom 期限(permanent 等)直接看 hasEnded;custom 按起止时间判断
|
||||
* @param vote 投票对象(含 spec.timeLimit/hasEnded/startDate/endDate)
|
||||
* @returns { state: '未开始' | '进行中' | '已结束', color: 'orange' | 'green' | 'red' }
|
||||
* @returns { state: '未开始' | '进行中' | '已结束', color: unocss 文字色类, bgColor: unocss 背景色类 }
|
||||
*/
|
||||
export function calcVoteState(vote: { spec?: { timeLimit?: string; hasEnded?: boolean; startDate?: string; endDate?: string; [key: string]: unknown } }): {
|
||||
state: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
} {
|
||||
if (vote.spec?.timeLimit !== 'custom') {
|
||||
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中'];
|
||||
|
||||
Reference in New Issue
Block a user