diff --git a/src/api/types/uni-halo.ts b/src/api/types/uni-halo.ts index f48c8f9..d2ae4fb 100644 --- a/src/api/types/uni-halo.ts +++ b/src/api/types/uni-halo.ts @@ -20,6 +20,8 @@ export interface IPluginConfig { toolsPlugin?: { Authorization?: string } & Record linksPlugin?: Record linksSubmitPlugin?: { Authorization?: string } & Record + /** 链接配置(插件端 spec.linkInfo 直接下发到本键,字段名无映射:displayName/miniProgramCode/link/description/applyRemark/authorName/avatar/website) */ + linkInfo?: Record doubanPlugin?: { position?: string } & Record [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) */ diff --git a/src/api/uni-halo.ts b/src/api/uni-halo.ts index b23e3bd..fe0dec7 100644 --- a/src/api/uni-halo.ts +++ b/src/api/uni-halo.ts @@ -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>('/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>(`/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>( `/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>( + '/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>( '/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>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-stories', { - params, + params: { ...params, ...(token ? { token } : {}) }, meta: { requestFrom: RequestFrom.Halo }, }) } diff --git a/src/components/uh-article-card/uh-article-card.vue b/src/components/uh-article-card/uh-article-card.vue index 7d6d416..558c90d 100644 --- a/src/components/uh-article-card/uh-article-card.vue +++ b/src/components/uh-article-card/uh-article-card.vue @@ -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(() => { - 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' } diff --git a/src/components/uh-home-quick-nav/uh-home-quick-nav.vue b/src/components/uh-home-quick-nav/uh-home-quick-nav.vue index b5ef8a3..abb33bd 100644 --- a/src/components/uh-home-quick-nav/uh-home-quick-nav.vue +++ b/src/components/uh-home-quick-nav/uh-home-quick-nav.vue @@ -1,4 +1,4 @@ - \ No newline at end of file + diff --git a/src/components/uh-links-mini-info/uh-links-mini-info.vue b/src/components/uh-links-mini-info/uh-links-mini-info.vue index 9d2772d..106c6b2 100644 --- a/src/components/uh-links-mini-info/uh-links-mini-info.vue +++ b/src/components/uh-links-mini-info/uh-links-mini-info.vue @@ -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 + const cfg = (appConfigStore.configs.pluginConfig?.linkInfo || {}) as Record 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'), } diff --git a/src/config/appConfig.ts b/src/config/appConfig.ts index 8332e78..0a33c1b 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -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, }, diff --git a/src/config/appSettings.ts b/src/config/appSettings.ts index 9c907ab..69e604a 100644 --- a/src/config/appSettings.ts +++ b/src/config/appSettings.ts @@ -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 - /** 联系博主页面 */ - 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', } diff --git a/src/hooks/usePreferenceRows.ts b/src/hooks/usePreferenceRows.ts index 545b13f..89c68dd 100644 --- a/src/hooks/usePreferenceRows.ts +++ b/src/hooks/usePreferenceRows.ts @@ -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')); } diff --git a/src/pages-blog/archives/archives.vue b/src/pages-blog/archives/archives.vue index a4620c9..e107908 100644 --- a/src/pages-blog/archives/archives.vue +++ b/src/pages-blog/archives/archives.vue @@ -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() diff --git a/src/pages-blog/articles/articles.vue b/src/pages-blog/articles/articles.vue index 281e2f2..5f43aa8 100644 --- a/src/pages-blog/articles/articles.vue +++ b/src/pages-blog/articles/articles.vue @@ -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() diff --git a/src/pages-blog/love/album.vue b/src/pages-blog/love/album.vue index b8d8e69..c7e510f 100644 --- a/src/pages-blog/love/album.vue +++ b/src/pages-blog/love/album.vue @@ -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) } } } diff --git a/src/pages-blog/love/list.vue b/src/pages-blog/love/list.vue index 7cc438c..3dc6749 100644 --- a/src/pages-blog/love/list.vue +++ b/src/pages-blog/love/list.vue @@ -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 { diff --git a/src/pages-blog/love/love.vue b/src/pages-blog/love/love.vue index d47d7b7..9aa5d8c 100644 --- a/src/pages-blog/love/love.vue +++ b/src/pages-blog/love/love.vue @@ -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 = { + stories: 'ourStory', + album: 'lovePhoto', + list: 'loveDaily', + } + + /** 模块密码弹窗状态 */ + const passwordModalVisible = ref(false) + const pendingPage = ref('') + const pendingModule = ref('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 @@ + + + + + 请输入访问密码 + + {{ pendingModule === 'ourStory' ? '恋爱故事' : pendingModule === 'lovePhoto' ? '恋爱相册' : '恋爱清单' }}已设置访问密码,输入后进入 + + + + 取消 + + {{ unlocking ? '验证中…' : '进入' }} + + + + \ No newline at end of file diff --git a/src/pages/tabbar/home/home.vue b/src/pages/tabbar/home/home.vue index 7218543..006b7da 100644 --- a/src/pages/tabbar/home/home.vue +++ b/src/pages/tabbar/home/home.vue @@ -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() { diff --git a/src/utils/loveModuleToken.ts b/src/utils/loveModuleToken.ts new file mode 100644 index 0000000..7703e40 --- /dev/null +++ b/src/utils/loveModuleToken.ts @@ -0,0 +1,44 @@ +/** + * 恋爱模块入口解锁 token 管理(插件端 POST /love-modules/unlock 签发,HMAC 30 分钟有效) + * 模块 scope:ourStory(恋爱故事) / 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>(LOVE_MODULE_TOKEN_KEY) + return cache?.[module] +} + +/** 保存指定模块的解锁 token(插件端 30 分钟有效,过期由服务端 401 兜底重新解锁) */ +export function setLoveModuleToken(module: LoveModuleKey, token: string): void { + const cache = getCache>(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>(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 +} diff --git a/src/utils/preference.test.ts b/src/utils/preference.test.ts index 68d7e4c..0aa9cf5 100644 --- a/src/utils/preference.test.ts +++ b/src/utils/preference.test.ts @@ -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('updateLocalPrefs:null 删除该键(回退跟随站点默认)', () => { - 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('collectSiteDefaults:banner 站点默认映射(showIndicator→useDot)', () => { - const site = collectSiteDefaults({ - pageConfig: { - homeConfig: { - bannerConfig: { showIndicator: false, dotPosition: 'bottom' }, - }, - }, - }) - expect(site.banner).toEqual({ useDot: false, dotPosition: 'bottom' }) - }) - - it('collectSiteDefaults:preferences(L0)映射到 layout 页面分组/isAvatarRadius', () => { + it('collectSiteDefaults:preferences(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).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).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 且不写新键', () => { diff --git a/src/utils/preference.ts b/src/utils/preference.ts index c723d3b..826575b 100644 --- a/src/utils/preference.ts +++ b/src/utils/preference.ts @@ -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): LocalPrefs { const result: LocalPrefs = {} @@ -76,40 +76,26 @@ export function collectSiteDefaults(configs: Partial): 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 }