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