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

refactor: 重构偏好设置与恋爱模块,对齐插件端字段结构

1. 重构布局偏好字段映射,移除嵌套layout结构,直接使用插件端同名字段
2. 新增恋爱模块token管理与密码解锁功能,支持恋爱故事/相册/清单的密码保护
3. 重构首页快捷导航,支持插件端直接配置导航项
4. 清理废弃配置字段,统一站点默认值与插件端结构
5. 修复文章卡片布局读取逻辑,适配新的偏好字段结构
This commit is contained in:
小莫唐尼
2026-09-10 00:07:33 +08:00
parent b0f101c96c
commit 260d6965e3
19 changed files with 467 additions and 344 deletions
+15 -2
View File
@@ -20,6 +20,8 @@ export interface IPluginConfig {
toolsPlugin?: { Authorization?: string } & Record<string, unknown>
linksPlugin?: Record<string, unknown>
linksSubmitPlugin?: { Authorization?: string } & Record<string, unknown>
/** 链接配置(插件端 spec.linkInfo 直接下发到本键,字段名无映射:displayName/miniProgramCode/link/description/applyRemark/authorName/avatar/website */
linkInfo?: Record<string, unknown>
doubanPlugin?: { position?: string } & Record<string, unknown>
[key: string]: unknown
}
@@ -63,6 +65,18 @@ export interface IPageConfig {
/** 是否显示快捷导航(首页) */
useQuickNavigation?: boolean
bannerConfig?: IBannerConfig
/** 首页快捷导航项(插件端「通用配置 → 页面设置 → 首页」配置,字段命名与插件端一致,
* 数组顺序 = 展示顺序;未配置/为空时客户端回退内置默认项) */
quickNavigation?: Array<{
key?: string
title?: string
color?: string
bgColor?: string
iconPrefix?: string
icon?: string
path?: string
visible?: boolean
}>
/** 首页精选分类引用(插件端「通用配置 → 页面设置 → 首页」配置,固定最多 3 个,
* 快照含名称/封面/排序权重,数组顺序 = 展示顺序;配置模式下直接映射渲染不发请求,
* 未配置/为空时回退默认取数) */
@@ -127,8 +141,7 @@ export interface IAppConfig {
auditConfig?: IAuditConfig
/**
* 站点级展示偏好默认(L0,插件端 GeneralConfig.preferences 经 getConfigs additive 下发;
* 客户端 layout.{home,articles,archives}.{listLayout,cardType}/isAvatarRadius 的站点默认来源,
* 本地偏好可覆盖;字段映射见 hermes/preferences.md §3)
* 字段名与客户端偏好设置一致,客户端直接透传消费、不做映射,本地偏好可覆盖)
*/
preferences?: {
/** 首页列表布局(h_row_col1/2 旧值由前端归一化为 single/double) */
+34 -12
View File
@@ -5,6 +5,7 @@ import { http } from '@/http/alova'
import { RequestFrom } from '@/http/tools/enum'
import type { IResponse } from '@/http/types'
import { getCache } from '@/utils/storage'
import { getLoveModuleToken } from '@/utils/loveModuleToken'
import { getNologinEmail, getOpenid } from '@/utils/auth'
import { getPersonalToken } from '@/store/token'
import type {
@@ -201,51 +202,71 @@ export function getLoveConfig() {
}
/**
* 获取恋爱相册列表
* 获取恋爱相册列表(lovePhoto 模块设密码时需携带模块解锁 token)
*/
export function getLoveAlbums(params: ILoveAlbumListReq) {
const token = getLoveModuleToken('lovePhoto')
return http.Get<IResponse<ILoveAlbumListRes>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-albums', {
params,
cacheFor: 0,
params: { ...params, ...(token ? { token } : {}) },
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取恋爱相册详情
* 获取恋爱相册详情(lovePhoto 模块设密码时需携带模块解锁 token)
*/
export function getLoveAlbumByName(name: string, params: ILoveAlbumListReq) {
const token = getLoveModuleToken('lovePhoto')
return http.Get<IResponse<ILoveAlbum>>(`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-albums/${name}`, {
params,
cacheFor: 0,
params: { ...params, ...(token ? { token } : {}) },
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 密码解锁相册
* 密码解锁相册(lovePhoto 模块设密码时需携带模块解锁 token)
*/
export function unlockAlbum(name: string, password: string, captcha?: ICaptchaQuery | null) {
const token = getLoveModuleToken('lovePhoto')
return http.Post<IResponse<{ token: string, photos?: unknown[] }>>(
`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-albums/${name}/unlock`,
{
password,
},
{
params: buildCaptchaQuery(captcha),
params: { ...buildCaptchaQuery(captcha), ...(token ? { token } : {}) },
meta: { requestFrom: RequestFrom.Halo },
},
)
}
/**
* 获取恋爱清单列表(分页)
* 恋爱模块入口解锁(模块密码,签发 30 分钟 token;模块:ourStory/lovePhoto/loveDaily
*/
export function unlockLoveModule(module: string, password: string) {
return http.Post<IResponse<{ token: string }>>(
'/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-modules/unlock',
{
module,
password,
},
{
meta: { requestFrom: RequestFrom.Halo },
},
)
}
/**
* 获取恋爱清单列表(分页)(loveDaily 模块设密码时需携带模块解锁 token)
*/
export function getLoveDailyItems(params: ILoveDailyItemListReq) {
const token = getLoveModuleToken('loveDaily')
return http.Get<IResponse<ILoveDailyItemListRes>>(
'/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-daily-items',
{
params,
params: { ...params, ...(token ? { token } : {}) },
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo },
},
@@ -253,11 +274,12 @@ export function getLoveDailyItems(params: ILoveDailyItemListReq) {
}
/**
* 获取恋爱故事列表
* 获取恋爱故事列表(ourStory 模块设密码时需携带模块解锁 token)
*/
export function getLoveStories(params: ILoveStoryListReq) {
const token = getLoveModuleToken('ourStory')
return http.Get<IResponse<ILoveStoryListRes>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-stories', {
params,
params: { ...params, ...(token ? { token } : {}) },
meta: { requestFrom: RequestFrom.Halo },
})
}
@@ -98,19 +98,26 @@
const isGrid = computed(() => props.variant === 'grid')
/** 各页面卡片样式字段名(与插件端 preferences 字段一致) */
const CARD_TYPE_KEY: Record<'home' | 'articles' | 'archives', string> = {
home: 'homeCardType',
articles: 'articleCardType',
archives: 'archivesCardType',
}
/** 实际生效布局:显式 layout > 按页面读取全局 cardType(首页/文章列表/文章归档)> image_top;窄列场景左右布局回退上图下文 */
const effectiveLayout = computed<CardLayout>(() => {
const _layout = settingStore.settings.layout
const settings = settingStore.settings
const page = props.from === 'home' || props.from === 'articles' || props.from === 'archives'
? props.from
: null
let raw = props.layout
if (!raw) {
raw = page
? (_layout[page].cardType as CardLayout)
? (settings[CARD_TYPE_KEY[page]] as CardLayout)
: 'image_top'
}
const narrow = isGrid.value || (props.from === 'home' && _layout.home.listLayout === 'double')
const narrow = isGrid.value || (props.from === 'home' && settings.homeListLayout === 'double')
if (narrow && raw !== 'image_top') {
return 'image_top'
}
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import { useAppConfigStore } from '@/store/appConfig'
const appConfigStore = useAppConfigStore()
@@ -10,81 +10,119 @@
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
/** 快捷导航列表(由配置控制显隐) */
/** 快捷导航项(字段命名与插件端 quickNavigation 一致:key/title/color/bgColor/iconPrefix/icon/path/visible,无 borderColor */
interface QuickNavItem {
key: string
title?: string
color?: string
bgColor?: string
iconPrefix?: string
icon?: string
path?: string
visible?: boolean
}
/** 内置默认项(未配置时回退;字段命名与插件端一致,可作配置缺失字段的兜底) */
const DEFAULT_NAV_LIST: QuickNavItem[] = [
{
key: 'archives',
title: '文章归档',
color: '#03A9F4',
bgColor: 'rgba(3, 169, 244, 0.14)',
iconPrefix: 'uhemoji2-icon',
icon: '-mask',
path: '/pages-blog/archives/archives',
visible: true,
},
{
key: 'vote',
title: '投票中心',
color: '#00BCD4',
bgColor: 'rgba(0, 188, 212, 0.14)',
iconPrefix: 'uhemoji2-icon',
icon: '-confused',
path: '/pages-blog/votes/votes',
visible: true,
},
{
key: 'disclaimers',
title: '友情链接',
color: '#009688',
bgColor: 'rgba(0, 150, 136, 0.14)',
iconPrefix: 'uhemoji2-icon',
icon: '-wink',
path: '/pages-blog/friend-links/friend-links',
visible: true,
},
{
key: 'love',
title: '恋爱日记',
color: '#FF4C67',
bgColor: 'rgba(255, 76, 103, 0.14)',
iconPrefix: 'uhemoji2-icon',
icon: '-in-love',
path: '/pages-blog/love/love',
visible: true,
},
{
key: 'contact-blogger',
title: '联系博主',
color: '#FF9800',
bgColor: 'rgba(255, 152, 0, 0.14)',
iconPrefix: 'uhemoji2-icon',
icon: '-cool',
path: '/pages-blog/contact/contact',
visible: true,
},
]
/** 快捷导航列表:优先读插件端配置(零映射,visible 过滤);未配置/为空回退内置默认(保留原显隐推导) */
const navList = computed(() => {
const loveEnabled = !!(haloConfigs.value.loveConfig as { loveEnabled ?: boolean })?.loveEnabled
const socialEnabled = !!(haloConfigs.value.authorConfig?.social as { enabled ?: boolean } | undefined)?.enabled
// <wd-icon class-prefix="uhemoji-icon" name="-smile-" size="32rpx" />
return [
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
color: '#03A9F4',
bgGlass: 'rgba(3, 169, 244, 0.14)',
borderColor: 'rgba(3, 169, 244, 0.35)',
iconPrefix: 'uhemoji2-icon',
icon: '-mask',
path: '/pages-blog/archives/archives',
show: true,
},
{
key: 'vote',
title: '投票中心',
color: '#00BCD4',
bgGlass: 'rgba(0, 188, 212, 0.14)',
borderColor: 'rgba(0, 188, 212, 0.35)',
iconPrefix: 'uhemoji2-icon',
icon: '-confused',
path: '/pages-blog/votes/votes',
// show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
show: true,
},
{
key: 'disclaimers',
title: '友情链接',
color: '#009688',
bgGlass: 'rgba(0, 150, 136, 0.14)',
borderColor: 'rgba(0, 150, 136, 0.35)',
iconPrefix: 'uhemoji2-icon',
icon: '-wink',
path: '/pages-blog/friend-links/friend-links',
// show: calcLinksPluginEnabled.value,
show: true,
},
{
key: 'love',
title: '恋爱日记',
color: '#FF4C67',
bgGlass: 'rgba(255, 76, 103, 0.14)',
borderColor: 'rgba(255, 76, 103, 0.075)',
iconPrefix: 'uhemoji2-icon',
icon: '-in-love',
path: '/pages-blog/love/love',
// show: loveEnabled,
show: true,
},
{
key: 'contact-blogger',
title: '联系博主',
color: '#FF9800',
bgGlass: 'rgba(255, 152, 0, 0.14)',
borderColor: 'rgba(255, 152, 0, 0.35)',
iconPrefix: 'uhemoji2-icon',
icon: '-cool',
path: '/pages-blog/contact/contact',
show: socialEnabled,
},
].filter(item => item.show)
const loveEnabled = !!(haloConfigs.value.loveConfig as { loveEnabled ?: boolean })?.loveEnabled
const configured = haloConfigs.value.pageConfig?.homeConfig?.quickNavigation
let list: QuickNavItem[]
if (configured && configured.length) {
// 配置模式:以配置项为准,缺失字段(icon/iconPrefix/color/path 等)按 key 从默认项兜底
list = configured.map(item => {
const fallback = DEFAULT_NAV_LIST.find(d => d.key === item.key) || DEFAULT_NAV_LIST[0]
return { ...fallback, ...item }
})
}
else {
// 默认模式(老部署无配置):回退内置默认项,保留原显隐推导
list = DEFAULT_NAV_LIST.map(item => {
if (item.key === 'archives') {
return { ...item, title: calcAuditModeEnabled.value ? '内容归档' : item.title }
}
if (item.key === 'contact-blogger') {
return { ...item, visible: socialEnabled }
}
if (item.key === 'love') {
return { ...item, visible: loveEnabled }
}
if (item.key === 'vote') {
return { ...item, visible: calcVotePluginEnabled.value }
}
if (item.key === 'disclaimers') {
return { ...item, visible: calcLinksPluginEnabled.value }
}
return item
})
}
return list.filter(item => item.visible !== false)
})
function handleClickNav(item : { path : string }) {
function handleClickNav(item : { path ?: string }) {
if (!item.path)
return
uni.navigateTo({ url: item.path })
}
</script>
<template>
<view v-if="navList.length" class="box-border overflow-hidden rounded-xl p-3 px-4 mb-3">
<view v-if="calcIsShowQuickNavigationEnabled && navList.length" class="box-border overflow-hidden rounded-xl p-3 px-4 mb-3">
<uh-section-title class="mb-4">
快捷导航
</uh-section-title>
@@ -94,7 +132,7 @@
<view
class="uh-global-card-glass uh-shadow-xs h-13 w-13 flex items-center justify-center rounded-2xl border transition-transform active:scale-90"
:style="{
backgroundColor: item.bgGlass
backgroundColor: item.bgColor
}">
<wd-icon :class-prefix="item.iconPrefix" :name="item.icon" size="64rpx"/>
</view>
@@ -3,7 +3,9 @@
* 小程序友链信息弹窗
* 展示本站小程序申请提交的信息,字段结构与小程序提交申请弹窗(uh-links-mini-apply)一致:
* 小程序名称/太阳码/跳转地址/作者昵称/作者头像/作者网站/描述/申请说明/邮箱
* 数据源:linksSubmitPlugin 配置(blogName→名称、blogLogo→太阳码、blogUrl→跳转地址、blogDesc→描述)
* 数据源:插件端 getConfigs.pluginConfig.linkInfo(字段名与插件端一致,无映射;
* displayName/miniProgramCode/link/description/applyRemark/authorName/avatar/website;
* email 不在插件端维护,读不到时弹窗该栏自动隐藏)
*/
import { computed, ref, watch } from 'vue'
import { useAppConfigStore } from '@/store/appConfig'
@@ -22,18 +24,18 @@ const emit = defineEmits<{
const isShow = ref(false)
const appConfigStore = useAppConfigStore()
/** 小程序申请信息(字段与 uh-links-mini-apply 表单一致,从 linksSubmitPlugin 配置读取) */
/** 小程序申请信息(字段与 uh-links-mini-apply 表单一致,从插件端 linkInfo 配置直接读取,字段名无映射) */
const miniInfo = computed(() => {
const cfg = (appConfigStore.configs.pluginConfig?.linksSubmitPlugin || {}) as Record<string, unknown>
const cfg = (appConfigStore.configs.pluginConfig?.linkInfo || {}) as Record<string, unknown>
const str = (key: string, fallback = '') => String(cfg[key] || fallback || '')
return {
displayName: str('blogName'),
miniProgramCode: str('blogLogo'),
link: str('blogUrl'),
displayName: str('displayName'),
miniProgramCode: str('miniProgramCode'),
link: str('link'),
authorName: str('authorName'),
avatar: str('avatar'),
website: str('website'),
description: str('blogDesc'),
description: str('description'),
applyRemark: str('applyRemark'),
email: str('email'),
}
+5 -16
View File
@@ -2,15 +2,13 @@
* 应用配置默认值(源自旧项目 config/index.js 的 DefaultAppConfigs)
* 与 src/api/uni-halo.ts 的 getAppConfigs(plugin-uni-halo/getConfigs)配合,deepMerge 使用
* 页面/组件依赖其字段结构(pluginConfig.toolsPlugin.Authorization 等),改动需谨慎
* 2026-09-08 清理:与插件端 getConfigs 结构统一,移除已下线/无消费的默认字段
* (tokenConfig/pageTitle/bannerConfig/categoryConfig.type 等)
*/
import type { IAppConfig } from '@/api/types/uni-halo'
export const DefaultAppConfigs: IAppConfig = {
basicConfig: {
tokenConfig: {
personalToken: '',
},
},
basicConfig: {},
loveConfig: {},
imagesConfig: {},
authorConfig: {},
@@ -19,6 +17,7 @@ export const DefaultAppConfigs: IAppConfig = {
votePlugin: {},
toolsPlugin: {},
linksPlugin: {},
// 保留:友链提交授权头(pluginConfig.linksSubmitPlugin.Authorization)与站点信息展示仍读取
linksSubmitPlugin: {},
doubanPlugin: {
position: 'bottom',
@@ -26,19 +25,9 @@ export const DefaultAppConfigs: IAppConfig = {
},
pageConfig: {
homeConfig: {
pageTitle: '首页',
useCategory: true,
bannerConfig: {
enabled: true,
showTitle: true,
showIndicator: true,
height: '400rpx',
dotPosition: 'right',
},
},
categoryConfig: {
type: 'list',
},
categoryConfig: {},
momentConfig: {
useTagRandomColor: true,
},
+19 -80
View File
@@ -1,89 +1,28 @@
/**
* 应用设置默认值与类型(源自旧项目 utils/app.js 的 _DefaultAppSettings)
* 布局偏好字段命名与插件端 getConfigs.preferences 一致(2026-09-08 起去映射,以插件端字段为准)
* 2026-09-08 清理:banner/ad/gallery/links/about/article/contact 等无消费字段已移除,
* 仅保留偏好相关字段(与插件端 preferences 结构对齐)
*/
/** 单页布局偏好(列表布局 + 卡片样式,与后端 preferences 分区对齐) */
export interface IPageLayoutPref {
/** 列表布局:single=单列 / double=双列 */
listLayout: string
/** 卡片样式(组件 layout 值):image_top=上图下文 / image_right=左文右图 / image_bottom=上文下图(社交卡片) / image_left=左图右文 */
cardType: string
}
export interface IAppSettings {
/** 评论头像是否圆形 */
isAvatarRadius: boolean
banner: {
useDot: boolean
dotPosition: string
}
/** 布局配置(按页面分组,每组可独立自定义) */
layout: {
/** 首页 */
home: IPageLayoutPref
/** 文章列表页 */
articles: IPageLayoutPref
/** 文章归档页 */
archives: IPageLayoutPref
}
/** 广告配置 */
ad: {
/** 屏蔽广告时长,时间到后自动恢复展示(单位小时) */
timeout: number
/** 是否屏蔽广告 */
disabled: boolean
}
gallery: {
/** 是否使用瀑布流 */
useWaterfull: boolean
}
links: {
useSimple: boolean
useGroup: boolean
}
about: {
/** 显示后台登录入口 */
showAdmin: boolean
/** 显示所有的统计信息(关于页面) */
showAllCount: boolean
}
/** 文章配置 */
article: Record<string, unknown>
/** 联系博主页面 */
contact: {
/** 链接是否使用复制的方式,否则直接在内部打开 */
isLinkCopy: boolean
}
/** 评论头像是否圆形(插件端字段 avatarRadius */
avatarRadius: boolean
/** 布局偏好(按页面分组,字段名 = 插件端 preferences 字段名,可直接消费 getConfigs) */
homeListLayout: string
homeCardType: string
articlesListLayout: string
articleCardType: string
archivesListLayout: string
archivesCardType: string
}
export const DefaultAppSettings: IAppSettings = {
isAvatarRadius: false,
banner: {
useDot: true,
dotPosition: 'right',
},
layout: {
home: { listLayout: 'single', cardType: 'image_top' },
articles: { listLayout: 'double', cardType: 'image_top' },
archives: { listLayout: 'single', cardType: 'image_top' },
},
ad: {
timeout: 3,
disabled: false,
},
gallery: {
useWaterfull: true,
},
links: {
useSimple: false,
useGroup: false,
},
about: {
showAdmin: false,
showAllCount: false,
},
article: {},
contact: {
isLinkCopy: true,
},
avatarRadius: false,
homeListLayout: 'single',
homeCardType: 'image_top',
articlesListLayout: 'double',
articleCardType: 'image_top',
archivesListLayout: 'single',
archivesCardType: 'image_top',
}
+18 -13
View File
@@ -26,13 +26,13 @@ export const PAGE_GROUPS = [
{ key: 'archives', label: '归档页面' }
];
/** 布局偏好字段(页面分组 × 列表布局/卡片样式) */
/** 布局偏好字段(页面分组 × 列表布局/卡片样式;path 直接为插件端顶层字段名,无映射) */
export const LAYOUT_PREFS: PrefDef[] = PAGE_GROUPS.flatMap((group) => [
{
key: `${group.key}ListLayout`,
label: '列表布局',
kind: 'enum',
path: ['layout', group.key, 'listLayout'],
path: [`${group.key}ListLayout`],
options: [
{ label: '单列', value: 'single' },
{ label: '双列', value: 'double' }
@@ -42,7 +42,7 @@ export const LAYOUT_PREFS: PrefDef[] = PAGE_GROUPS.flatMap((group) => [
key: `${group.key}CardType`,
label: '卡片样式',
kind: 'enum',
path: ['layout', group.key, 'cardType'],
path: [`${group.key}CardType`],
options: [
{ label: '上图下文', value: 'image_top' },
{ label: '左文右图', value: 'image_right' },
@@ -52,8 +52,8 @@ export const LAYOUT_PREFS: PrefDef[] = PAGE_GROUPS.flatMap((group) => [
}
]);
/** 功能偏好字段 */
export const FEATURE_PREFS: PrefDef[] = [{ key: 'isAvatarRadius', label: '是否圆形头像', kind: 'bool', path: ['isAvatarRadius'] }];
/** 功能偏好字段(字段名与插件端一致) */
export const FEATURE_PREFS: PrefDef[] = [{ key: 'avatarRadius', label: '是否圆形头像', kind: 'bool', path: ['avatarRadius'] }];
/** 顶部分段器(布局 / 功能) */
export const SETTING_TABS: { key: 'layout' | 'feature'; label: string }[] = [
@@ -126,12 +126,14 @@ export function usePreferenceRows() {
const layoutRows = computed(() => buildRows(LAYOUT_PREFS));
const featureRows = computed(() => buildRows(FEATURE_PREFS));
/** 布局设置按页面分组的展示行 */
/** 布局设置按页面分组的展示行(列表布局 + 卡片样式两行) */
const layoutGroups = computed(() =>
PAGE_GROUPS.map((group) => ({
key: group.key,
label: group.label,
rows: layoutRows.value.filter((row) => row.path[1] === group.key)
rows: layoutRows.value.filter((row) =>
row.path[0] === `${group.key}ListLayout` || row.path[0] === `${group.key}CardType`
)
}))
);
@@ -150,17 +152,20 @@ export function usePreferenceRows() {
settingStore.savePreference(buildPatch(path, next));
}
/** 给定页面分组下字段路径,判断该页面列表布局是否为双列 */
/** 给定字段路径,判断该页面列表布局是否为双列(path[0] 即插件端顶层字段名;
* 列表布局行直接取值,卡片样式行推导同组 ListLayout 字段) */
function isDoubleColumn(path: string[]): boolean {
if (path.length >= 2 && path[0] === 'layout') {
return prefValueOf(['layout', path[1], 'listLayout']) === 'double';
const field = path[0] || ''
const listKey = field.endsWith('CardType') ? field.replace(/CardType$/, 'ListLayout') : field
if (listKey.endsWith('ListLayout')) {
return prefValueOf([listKey]) === 'double';
}
return false;
}
/** 卡片样式选项是否因双列约束被禁用(双列仅允许 image_top) */
function isCardTypeOptionDisabled(path: string[], value: string): boolean {
return path[2] === 'cardType' && isDoubleColumn(path) && value !== 'image_top';
return path[0].endsWith('CardType') && isDoubleColumn(path) && value !== 'image_top';
}
function handleChoose(path: string[], value: string | null): void {
@@ -169,8 +174,8 @@ export function usePreferenceRows() {
} else {
settingStore.savePreference(buildPatch(path, value));
// 双列约束:列表布局改为双列时,卡片样式强制为 image_top
if (path[0] === 'layout' && path[2] === 'listLayout' && value === 'double') {
const cardTypePath = ['layout', path[1], 'cardType'];
if (path[0].endsWith('ListLayout') && value === 'double') {
const cardTypePath = [path[0].replace(/ListLayout$/, 'CardType')];
if (prefValueOf(cardTypePath) !== 'image_top') {
settingStore.savePreference(buildPatch(cardTypePath, 'image_top'));
}
+1 -1
View File
@@ -23,7 +23,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const settingStore = useSettingStore()
/** 归档页列表布局(偏好设置驱动:single=单列 / double=双列) */
const archivesListLayout = computed(() => settingStore.settings.layout.archives.listLayout)
const archivesListLayout = computed(() => settingStore.settings.archivesListLayout)
/* ---------------- 状态 ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
+1 -1
View File
@@ -24,7 +24,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const settingStore = useSettingStore()
/** 文章列表页列表布局(偏好设置驱动:single=单列 / double=双列) */
const articlesListLayout = computed(() => settingStore.settings.layout.articles.listLayout)
const articlesListLayout = computed(() => settingStore.settings.articlesListLayout)
/* ---------------- 状态 ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
+4
View File
@@ -6,6 +6,7 @@ import { getLoveAlbumByName, getLoveAlbums } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { getCache, setCache } from '@/utils/storage'
import { handleLoveModuleLocked } from '@/utils/loveModuleToken'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { ILoveAlbum, ILovePhoto } from '@/api/types/uni-halo'
@@ -111,6 +112,8 @@ async function handleGetData() {
}
catch (e) {
console.error('获取相册失败', e)
// 模块锁 401:清除 token 并提示
handleLoveModuleLocked('lovePhoto', e)
updateLoadingStatus(DataLoadingStatusEnum.Error)
}
finally {
@@ -138,6 +141,7 @@ async function handleLoadUnlockedAlbumPhotos() {
}
catch (e) {
console.error('加载相册照片失败', e)
handleLoveModuleLocked('lovePhoto', e)
}
}
}
+3
View File
@@ -6,6 +6,7 @@
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getLoveDailyItems } from '@/api/uni-halo'
import { handleLoveModuleLocked } from '@/utils/loveModuleToken'
import { checkImageUrl } from '@/utils/url'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { ILoveDailyItem } from '@/api/types/uni-halo'
@@ -172,6 +173,8 @@
}
catch (e) {
console.error('获取清单失败', e)
// 模块锁 401:清除 token 并提示
handleLoveModuleLocked('loveDaily', e)
updateLoadingStatus(DataLoadingStatusEnum.Error)
}
finally {
+84 -7
View File
@@ -3,6 +3,8 @@
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { unlockLoveModule } from '@/api/uni-halo'
import { getLoveModuleToken, setLoveModuleToken, type LoveModuleKey } from '@/utils/loveModuleToken'
definePage({
style: {
@@ -29,9 +31,9 @@
waveImageUrl : string
heartImageUrl : string
}
ourStory : { enabled : boolean, iconUrl : string }
lovePhoto : { enabled : boolean, iconUrl : string }
loveDaily : { enabled : boolean, iconUrl : string }
ourStory : { enabled : boolean, passwordEnabled ?: boolean }
lovePhoto : { enabled : boolean, passwordEnabled ?: boolean }
loveDaily : { enabled : boolean, passwordEnabled ?: boolean }
[key : string] : unknown
}
@@ -50,9 +52,9 @@
waveImageUrl: '',
heartImageUrl: '',
},
ourStory: { enabled: false, iconUrl: '' },
lovePhoto: { enabled: false, iconUrl: '' },
loveDaily: { enabled: false, iconUrl: '' },
ourStory: { enabled: false, passwordEnabled: false },
lovePhoto: { enabled: false, passwordEnabled: false },
loveDaily: { enabled: false, passwordEnabled: false },
})
const loveDayCount = ref({ d: 0, h: 0, m: 0, s: 0 })
@@ -151,13 +153,66 @@
countDownFn()
}
/* ---------------- 跳转 ---------------- */
/* ---------------- 跳转(模块密码拦截) ---------------- */
/** 页面名 → 恋爱模块 scope 映射(stories→ourStory、album→lovePhoto、list→loveDaily */
const MODULE_KEY_MAP: Record<string, LoveModuleKey> = {
stories: 'ourStory',
album: 'lovePhoto',
list: 'loveDaily',
}
/** 模块密码弹窗状态 */
const passwordModalVisible = ref(false)
const pendingPage = ref('')
const pendingModule = ref<LoveModuleKey>('ourStory')
const modulePassword = ref('')
const unlocking = ref(false)
function handleToPage(pageName : string) {
const module = MODULE_KEY_MAP[pageName]
const moduleCfg = module
? (loveConfig.value[module] as { enabled ?: boolean, passwordEnabled ?: boolean })
: undefined
// 模块设了密码且本地无有效 token → 先弹密码框验证
if (moduleCfg?.passwordEnabled && !getLoveModuleToken(module)) {
pendingPage.value = pageName
pendingModule.value = module
modulePassword.value = ''
passwordModalVisible.value = true
return
}
uni.navigateTo({
url: `/pages-blog/love/${pageName}`,
})
}
/** 密码确认:unlock 签发 token 后进入模块 */
async function handleConfirmPassword() {
if (!modulePassword.value.trim()) {
uni.showToast({ icon: 'none', title: '请输入密码' })
return
}
try {
unlocking.value = true
const res = await unlockLoveModule(pendingModule.value, modulePassword.value)
const token = res.data?.token
if (token) {
setLoveModuleToken(pendingModule.value, token)
passwordModalVisible.value = false
uni.navigateTo({ url: `/pages-blog/love/${pendingPage.value}` })
}
else {
uni.showToast({ icon: 'none', title: '解锁失败,请重试' })
}
}
catch {
uni.showToast({ icon: 'none', title: '密码不正确' })
}
finally {
unlocking.value = false
}
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
syncLoveConfigFromStore()
@@ -256,6 +311,28 @@
</block>
</view>
</view>
<!-- 模块密码验证弹窗 -->
<view v-if="passwordModalVisible" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click="passwordModalVisible = false">
<view class="box-border w-4/5 rounded-2xl bg-white p-6" @click.stop>
<view class="text-center text-lg font-bold text-love">请输入访问密码</view>
<view class="mt-2 text-center text-xs text-gray-500">
{{ pendingModule === 'ourStory' ? '恋爱故事' : pendingModule === 'lovePhoto' ? '恋爱相册' : '恋爱清单' }}已设置访问密码输入后进入
</view>
<input v-model="modulePassword" password
class="mt-4 h-11 w-full rounded-xl border border-gray-200 bg-gray-50 px-4 text-sm outline-none"
placeholder="请输入密码" />
<view class="mt-5 flex items-center gap-3">
<view class="flex-1 rounded-xl bg-gray-100 py-2.5 text-center text-sm text-gray-600"
@click="passwordModalVisible = false">取消</view>
<view class="flex-1 rounded-xl bg-love py-2.5 text-center text-sm text-white"
:class="unlocking ? 'opacity-60' : ''" @click="handleConfirmPassword">
{{ unlocking ? '验证中' : '进入' }}
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
+5 -1
View File
@@ -3,6 +3,7 @@
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getLoveStories } from '@/api/uni-halo'
import { handleLoveModuleLocked } from '@/utils/loveModuleToken'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
@@ -116,7 +117,10 @@
}
catch (e) {
console.error('获取故事失败', e)
handleLoadFromLegacy()
// 模块锁 401:清除 token 并提示;命中 locked 时不做旧配置降级(需回入口重新解锁)
if (!handleLoveModuleLocked('ourStory', e)) {
handleLoadFromLegacy()
}
}
finally {
setTimeout(() => {
+44 -32
View File
@@ -168,37 +168,37 @@
</uh-section-title>
<view class="setting-sheet uh-global-card-glass overflow-hidden rounded-2xl">
<template v-for="(row, index) in featureRows" :key="row.key">
<!-- 布尔项:内联分段器(默认 / / ) -->
<view v-if="row.kind === 'bool'" class="box-border p-3"
:class="index < featureRows.length - 1 ? 'border-b border-black/5' : ''">
<view class="flex items-center justify-between">
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ row.label }}</text>
<view class="flex items-center gap-2">
<text v-if="row.following" class="row-sub text-2xs text-gray-400">默认</text>
<view v-else
class="rounded-full bg-secondary px-2 py-1 text-xs text-gray-900 leading-none">
已自定义
</view>
</view>
</view>
<view class="mt-3 flex flex-wrap gap-2">
<view class="rounded-full px-3 py-1 text-xs uh-global-card-glass shadow-none border"
:class="row.following ? 'bg-secondary font-bold' : 'text-gray-500'"
@click="handleRevert(row.path)">
默认
</view>
<view class="rounded-full px-3 py-1 text-xs uh-global-card-glass shadow-none border"
:class="!row.following && row.boolValue ? 'bg-secondary font-bold' : 'text-gray-500'"
@click="handleBoolChange(row.path, true)">
</view>
<view class="rounded-full px-3 py-1 text-xs uh-global-card-glass shadow-none border"
:class="!row.following && !row.boolValue ? 'bg-secondary font-bold' : 'text-gray-500'"
@click="handleBoolChange(row.path, false)">
</view>
</view>
</view>
<!-- 布尔项:内联分段器(默认 / / ) -->
<view v-if="row.kind === 'bool'" class="box-border p-3"
:class="index < featureRows.length - 1 ? 'border-b border-black/5' : ''">
<view class="flex items-center justify-between">
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ row.label }}</text>
<view class="flex items-center gap-2">
<text v-if="row.following" class="row-sub text-2xs text-gray-400">默认</text>
<view v-else
class="rounded-full bg-secondary px-2 py-1 text-xs text-gray-900 leading-none">
已自定义
</view>
</view>
</view>
<view class="mt-3 flex flex-wrap gap-2">
<view class="rounded-full px-3 py-1 text-xs uh-global-card-glass shadow-none border"
:class="row.following ? 'bg-secondary font-bold' : 'text-gray-500'"
@click="handleRevert(row.path)">
默认
</view>
<view class="rounded-full px-3 py-1 text-xs uh-global-card-glass shadow-none border"
:class="!row.following && row.boolValue ? 'bg-secondary font-bold' : 'text-gray-500'"
@click="handleBoolChange(row.path, true)">
</view>
<view class="rounded-full px-3 py-1 text-xs uh-global-card-glass shadow-none border"
:class="!row.following && !row.boolValue ? 'bg-secondary font-bold' : 'text-gray-500'"
@click="handleBoolChange(row.path, false)">
</view>
</view>
</view>
<!-- 枚举选择 -->
<view v-else class="pick-row flex items-center justify-between px-4 py-4"
:class="index < featureRows.length - 1 ? 'border-b border-black/5' : ''"
@@ -245,7 +245,8 @@
</view>
<!-- 选择器 -->
<wd-picker-view :columns="enumColumns" v-model="pickerValue"
custom-class="!p-0 !bg-transparent !rounded-xl overflow-hidden" @change="handlePickerChange" />
custom-class="uh-picker-view !p-0 !bg-transparent !rounded-xl overflow-hidden"
@change="handlePickerChange" />
<!-- 底部操作:取消 / 确认 -->
<view class="mt-4 flex items-center justify-center gap-x-3">
<uh-button custom-class="flex-1 py-2 uh-global-card-glass border !rounded-xl bg-white/90"
@@ -262,3 +263,14 @@
</uh-glass-popup>
</view>
</template>
<style scoped lang="scss">
:deep(.uh-picker-view) {
.wd-picker-view__mask {
background: transparent !important;
}
.wd-picker-view__roller{
border-radius: 16rpx !important;
}
}
</style>
+1 -1
View File
@@ -94,7 +94,7 @@
const globalAppSettings = computed(() => settingStore.settings)
/** 首页列表布局(偏好设置驱动:single=单列 / double=双列) */
const homeListLayout = computed(() => settingStore.settings.layout.home.listLayout)
const homeListLayout = computed(() => settingStore.settings.homeListLayout)
/* ---------------- 数据加载 ---------------- */
async function handleQuery() {
+44
View File
@@ -0,0 +1,44 @@
/**
* 恋爱模块入口解锁 token 管理(插件端 POST /love-modules/unlock 签发,HMAC 30 分钟有效)
* 模块 scopeourStory(恋爱故事) / lovePhoto(恋爱相册) / loveDaily(恋爱清单)
*/
import { delCache, getCache, setCache } from '@/utils/storage'
/** 恋爱模块解锁 token 存储键(module → token */
const LOVE_MODULE_TOKEN_KEY = 'uh_love_module_token_v1'
export type LoveModuleKey = 'ourStory' | 'lovePhoto' | 'loveDaily'
/** 读取指定模块的解锁 token(未解锁则 undefined */
export function getLoveModuleToken(module: LoveModuleKey): string | undefined {
const cache = getCache<Record<string, string>>(LOVE_MODULE_TOKEN_KEY)
return cache?.[module]
}
/** 保存指定模块的解锁 token(插件端 30 分钟有效,过期由服务端 401 兜底重新解锁) */
export function setLoveModuleToken(module: LoveModuleKey, token: string): void {
const cache = getCache<Record<string, string>>(LOVE_MODULE_TOKEN_KEY) || {}
cache[module] = token
setCache(LOVE_MODULE_TOKEN_KEY, cache)
}
/** 清除指定模块的解锁 token(401 locked 时调用,回到未解锁状态) */
export function clearLoveModuleToken(module: LoveModuleKey): void {
const cache = getCache<Record<string, string>>(LOVE_MODULE_TOKEN_KEY)
if (cache && module in cache) {
delete cache[module]
setCache(LOVE_MODULE_TOKEN_KEY, cache)
}
}
/** 恋爱模块数据接口 401 locked 处理(清除 token + 提示,返回是否命中 locked) */
export function handleLoveModuleLocked(module: LoveModuleKey, err: unknown): boolean {
// alova 错误:err.cause?.response?.status === 401 且响应体 reason === 'locked'
const status = (err as { cause?: { response?: { status?: number } } })?.cause?.response?.status
if (status === 401) {
clearLoveModuleToken(module)
uni.showToast({ icon: 'none', title: '访问密码已失效,请返回重新解锁' })
return true
}
return false
}
+47 -69
View File
@@ -35,37 +35,38 @@ describe('preference 基础读写', () => {
expect(readLocalPrefs()).toEqual({})
})
it('updateLocalPrefs嵌套字段增量合并', () => {
updateLocalPrefs({ layout: { home: { listLayout: 'double' } } })
updateLocalPrefs({ layout: { home: { cardType: 'image_bottom' } } })
it('updateLocalPrefs:字段增量合并(顶层插件字段名,无嵌套)', () => {
updateLocalPrefs({ homeListLayout: 'double' })
updateLocalPrefs({ homeCardType: 'image_bottom' })
expect(readLocalPrefs()).toEqual({
layout: { home: { listLayout: 'double', cardType: 'image_bottom' } },
homeListLayout: 'double',
homeCardType: 'image_bottom',
})
})
it('updateLocalPrefsnull 删除该键(回退跟随站点默认)', () => {
updateLocalPrefs({ layout: { home: { listLayout: 'double', cardType: 'image_bottom' } } })
updateLocalPrefs({ layout: { home: null } })
expect(readLocalPrefs()).toEqual({ layout: {} })
updateLocalPrefs({ homeListLayout: 'double', homeCardType: 'image_bottom' })
updateLocalPrefs({ homeListLayout: null })
expect(readLocalPrefs()).toEqual({ homeCardType: 'image_bottom' })
})
it('updateLocalPrefs(null):整体清空差异', () => {
updateLocalPrefs({ layout: { home: { listLayout: 'double' } } })
updateLocalPrefs({ homeListLayout: 'double' })
updateLocalPrefs(null)
expect(readLocalPrefs()).toEqual({})
expect(mem.has(LOCAL_PREFS_KEY)).toBe(false)
})
it('clearLocalPrefs:删除存储键', () => {
updateLocalPrefs({ layout: { home: { listLayout: 'double' } } })
updateLocalPrefs({ homeListLayout: 'double' })
clearLocalPrefs()
expect(readLocalPrefs()).toEqual({})
})
it('isLocalOverride:按路径判断是否被本地覆盖', () => {
updateLocalPrefs({ gallery: { useWaterfull: false } })
expect(isLocalOverride(readLocalPrefs(), ['gallery', 'useWaterfull'])).toBe(true)
expect(isLocalOverride(readLocalPrefs(), ['banner', 'useDot'])).toBe(false)
updateLocalPrefs({ homeListLayout: 'double' })
expect(isLocalOverride(readLocalPrefs(), ['homeListLayout'])).toBe(true)
expect(isLocalOverride(readLocalPrefs(), ['homeCardType'])).toBe(false)
})
})
@@ -76,26 +77,14 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
it('本地优先于站点默认,站点默认优先于内置默认', () => {
const merged = mergeWithDefaults(
{ layout: { home: { listLayout: 'single' } }, gallery: { useWaterfull: true } },
{ layout: { home: { listLayout: 'double' } } },
{ homeListLayout: 'single' },
{ homeListLayout: 'double' },
)
expect(merged.layout.home.listLayout).toBe('double')
expect(merged.gallery.useWaterfull).toBe(true)
expect(merged.isAvatarRadius).toBe(DefaultAppSettings.isAvatarRadius)
expect(merged.homeListLayout).toBe('double')
expect(merged.avatarRadius).toBe(DefaultAppSettings.avatarRadius)
})
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 页面分组/isAvatarRadius', () => {
it('collectSiteDefaultspreferences(L0)同名字段透传(含旧值归一化)', () => {
const site = collectSiteDefaults({
preferences: {
homeListLayout: 'h_row_col2',
@@ -106,12 +95,12 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
avatarRadius: true,
},
})
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)
expect(site.homeListLayout).toBe('double')
expect(site.homeCardType).toBe('image_bottom')
expect(site.articlesListLayout).toBe('single')
expect(site.articleCardType).toBe('image_left')
expect(site.archivesCardType).toBe('image_top')
expect(site.avatarRadius).toBe(true)
})
it('preferences L0 参与合并,本地未覆盖时跟随站点默认', () => {
@@ -123,9 +112,9 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
},
})
const merged = mergeWithDefaults(site, {})
expect(merged.layout.home.listLayout).toBe('double')
expect(merged.layout.articles.cardType).toBe('image_bottom')
expect(merged.isAvatarRadius).toBe(true)
expect(merged.homeListLayout).toBe('double')
expect(merged.articleCardType).toBe('image_bottom')
expect(merged.avatarRadius).toBe(true)
})
it('preferences L0 可被本地差异覆盖,重置后回退站点默认', () => {
@@ -136,29 +125,15 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
avatarRadius: true,
},
})
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)
})
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.listLayout).toBe(DefaultAppSettings.layout.home.listLayout)
const merged = mergeWithDefaults(site, { homeListLayout: 'single', avatarRadius: false })
expect(merged.homeListLayout).toBe('single')
expect(merged.articleCardType).toBe('image_bottom')
expect(merged.avatarRadius).toBe(false)
})
it('未知枚举值不回退抛错(跟随默认)', () => {
const merged = mergeWithDefaults({}, { layout: { home: { listLayout: 'not-exist' } } })
expect(merged.layout.home.listLayout).toBe('not-exist')
const merged = mergeWithDefaults({}, { homeListLayout: 'not-exist' })
expect(merged.homeListLayout).toBe('not-exist')
})
})
@@ -168,28 +143,31 @@ describe('migrateLegacyLocalPrefs', () => {
setupUniStorageMock()
})
it('旧 persist 存在时仅迁移被改过的叶子字段', () => {
const legacySettings: IAppSettings = JSON.parse(JSON.stringify(DefaultAppSettings))
// 旧结构 layout.home 为 string(列表布局),类型上绕过新结构约束
;(legacySettings.layout as unknown as Record<string, unknown>).home = 'h_row_col2'
legacySettings.gallery.useWaterfull = false
it('旧 persist 存在时仅迁移与新默认结构可对比的叶子字段(旧 layout 嵌套字段不迁移)', () => {
// 旧结构 layout.home 为 string(列表布局),类型上绕过新结构约束模拟旧数据
const legacySettings = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings & {
layout?: unknown
}
legacySettings.layout = { home: 'h_row_col2' }
legacySettings.avatarRadius = true
mem.set('setting', JSON.stringify({ settings: legacySettings }))
expect(migrateLegacyLocalPrefs()).toBe(true)
expect(readLocalPrefs()).toEqual({
layout: { home: 'h_row_col2' },
gallery: { useWaterfull: false },
avatarRadius: true,
})
})
it('已存在新差异键时不再重复迁移', () => {
updateLocalPrefs({ layout: { home: { listLayout: 'single' } } })
const legacySettings = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings
;(legacySettings.layout as unknown as Record<string, unknown>).home = 'h_row_col2'
updateLocalPrefs({ homeListLayout: 'single' })
const legacySettings = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings & {
layout?: unknown
}
legacySettings.layout = { home: 'h_row_col2' }
mem.set('setting', JSON.stringify({ settings: legacySettings }))
expect(migrateLegacyLocalPrefs()).toBe(false)
expect(readLocalPrefs()).toEqual({ layout: { home: { listLayout: 'single' } } })
expect(readLocalPrefs()).toEqual({ homeListLayout: 'single' })
})
it('无旧键或格式异常时返回 false 且不写新键', () => {
+16 -30
View File
@@ -56,12 +56,12 @@ export function clearLocalPrefs(): void {
/**
* 把 L0 站点默认(getConfigs 下发值)中与偏好相关的字段收集为本地差异形状的站点默认。
* 偏好字段与 getConfigs 字段完全同名,此处维护映射表:
* 2026-09-08 起去映射:偏好字段与 getConfigs.preferences 字段完全一致,只做值校验后透传,
* 不再改写为 layout.{home,articles,archives}.{listLayout,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。
* archivesListLayout/archivesCardType → 同名顶层字段;
* - preferences.avatarRadius → avatarRadius。
*/
export function collectSiteDefaults(configs: Partial<IAppConfig>): LocalPrefs {
const result: LocalPrefs = {}
@@ -76,40 +76,26 @@ export function collectSiteDefaults(configs: Partial<IAppConfig>): LocalPrefs {
return undefined
return v === 'h_row_col2' ? 'double' : v === 'h_row_col1' ? 'single' : v
}
/** 页面布局:后端字段名(列表布局 + 卡片样式)→ 本地嵌套路径 */
const setPage = (page: 'home' | 'articles' | 'archives', listKey: string, cardKey: string) => {
/** 布局字段透传:字段名与插件端一致,仅做值校验(有值才写入) */
const setLayout = (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 (listValue !== undefined) {
result[listKey] = listValue
}
if (cardValue !== undefined) {
result[cardKey] = cardValue
}
}
setPage('home', 'homeListLayout', 'homeCardType')
setPage('articles', 'articlesListLayout', 'articleCardType')
setPage('archives', 'archivesListLayout', 'archivesCardType')
setLayout('homeListLayout', 'homeCardType')
setLayout('articlesListLayout', 'articleCardType')
setLayout('archivesListLayout', 'archivesCardType')
if (typeof prefs.avatarRadius === 'boolean') {
result.isAvatarRadius = prefs.avatarRadius
result.avatarRadius = prefs.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
}