mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-12 16:40:40 +08:00
feat: 实现全站布局偏好系统,支持多页面独立配置
本次提交完成了完整的偏好设置体系重构: 1. 新增preferences.md文档说明布局偏好设计与映射规则 2. 重构布局配置结构,按页面分组管理列表布局与卡片样式 3. 实现后端preferences字段到本地配置的自动映射转换 4. 完成首页、文章列表、归档页的布局动态切换逻辑 5. 重构投票组件样式绑定,改用unocss类名实现样式分离 6. 新增偏好设置页面,支持按页面配置布局参数 7. 兼容旧版布局参数的自动归一化转换 8. 补充完整的单元测试用例覆盖新旧数据迁移场景
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
| [conventions.md](./conventions.md) | 代码规范:命名、SFC 结构、TS、状态、提交与验证命令 |
|
| [conventions.md](./conventions.md) | 代码规范:命名、SFC 结构、TS、状态、提交与验证命令 |
|
||||||
| [platforms.md](./platforms.md) | 平台适配手册:差异决策树、条件编译速查、本项目差异点表、多端地址 |
|
| [platforms.md](./platforms.md) | 平台适配手册:差异决策树、条件编译速查、本项目差异点表、多端地址 |
|
||||||
| [api.md](./api.md) | 请求层规范:分层、httpGet/Post 用法、错误四分类、401 双 token 策略 |
|
| [api.md](./api.md) | 请求层规范:分层、httpGet/Post 用法、错误四分类、401 双 token 策略 |
|
||||||
|
| [preferences.md](./preferences.md) | 偏好设置:两层结构、按页面分组的布局偏好、后端 getConfigs 字段映射 |
|
||||||
| [sop-new-page.md](./sop-new-page.md) | 新页面/组件/分包/tabbar/hooks SOP 与验证清单 |
|
| [sop-new-page.md](./sop-new-page.md) | 新页面/组件/分包/tabbar/hooks SOP 与验证清单 |
|
||||||
| [performance.md](./performance.md) | 性能与分包:主包体积、内置优化表、包体积检查、编码侧规则 |
|
| [performance.md](./performance.md) | 性能与分包:主包体积、内置优化表、包体积检查、编码侧规则 |
|
||||||
| [release.md](./release.md) | 发布流程:upload:mp 全流程、changesets、uvm 升级、环境切换、合入门禁 |
|
| [release.md](./release.md) | 发布流程:upload:mp 全流程、changesets、uvm 升级、环境切换、合入门禁 |
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 偏好设置(Preferences)
|
||||||
|
|
||||||
|
> 本文档定义客户端偏好设置的数据结构、取值约定与后端插件 `getConfigs.preferences` 下发字段的映射关系,
|
||||||
|
> 供「偏好设置」页面、各消费页面与后端插件配置同步参考。
|
||||||
|
> 详细设计(含与旧值体系差异)见 `.docs/preferences.md`。
|
||||||
|
|
||||||
|
## 1. 数据流(两层偏好)
|
||||||
|
|
||||||
|
读取优先级:**本地差异 > 站点默认(L0)> 客户端内置默认**。
|
||||||
|
|
||||||
|
- L0 站点默认:插件 `getConfigs` 下发,由 `collectSiteDefaults`(`src/utils/preference.ts`)收集为差异形状;
|
||||||
|
- L1 本地差异:storage key `uh_pref_local_v1`,只存与站点默认不同的字段(字段级覆盖,本地优先),值缺省/删除即回退跟随站点默认;
|
||||||
|
- 内置默认:`src/config/appSettings.ts` 的 `DefaultAppSettings`(无 L0 下发时的兜底)。
|
||||||
|
|
||||||
|
## 2. 布局偏好结构(按页面分组)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** src/config/appSettings.ts */
|
||||||
|
interface IPageLayoutPref {
|
||||||
|
/** 列表布局:single=单列 / double=双列 */
|
||||||
|
listLayout: string
|
||||||
|
/** 卡片样式(组件 layout 值):image_top=上图下文 / image_right=左文右图 / image_bottom=上文下图(社交卡片) / image_left=左图右文 */
|
||||||
|
cardType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
layout: {
|
||||||
|
home: IPageLayoutPref // 首页
|
||||||
|
articles: IPageLayoutPref // 文章列表页
|
||||||
|
archives: IPageLayoutPref // 文章归档页
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 取值约定
|
||||||
|
|
||||||
|
| 字段 | 可选值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `listLayout` | `single` / `double` | 单列 / 双列;「默认」= 跟随站点默认(本地不覆盖) |
|
||||||
|
| `cardType` | `image_top` / `image_right` / `image_bottom` / `image_left` | 上图下文 / 左文右图 / 上文下图(社交卡片) / 左图右文,与 `uh-article-card` 组件 layout 值一一对应 |
|
||||||
|
|
||||||
|
> 旧值体系(`lr_image_text` / `lr_text_image` / `tb_image_text` / `tb_text_image`)已废弃,统一改用组件 layout 值。
|
||||||
|
|
||||||
|
### 内置默认值(DefaultAppSettings)
|
||||||
|
|
||||||
|
| 页面 | listLayout | cardType |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| home(首页) | `single` | `image_bottom` |
|
||||||
|
| articles(文章列表) | `double` | `image_bottom` |
|
||||||
|
| archives(文章归档) | `single` | `image_bottom` |
|
||||||
|
|
||||||
|
## 3. 后端插件字段映射(getConfigs.preferences)
|
||||||
|
|
||||||
|
`collectSiteDefaults` 维护以下映射(字段缺省则跳过,不产生覆盖):
|
||||||
|
|
||||||
|
| 后端 `preferences` 字段 | 本地路径 | 说明 |
|
||||||
|
|------------------------|---------|------|
|
||||||
|
| `homeListLayout` | `layout.home.listLayout` | 兼容旧值 `h_row_col1/2`,归一化为 `single/double` |
|
||||||
|
| `homeCardType` | `layout.home.cardType` | 值为 `image_top/image_right/image_bottom/image_left` |
|
||||||
|
| `articlesListLayout` | `layout.articles.listLayout` | 新增 |
|
||||||
|
| `articleCardType` | `layout.articles.cardType` | 沿用旧字段名(兼容既有下发) |
|
||||||
|
| `archivesListLayout` | `layout.archives.listLayout` | 新增 |
|
||||||
|
| `archivesCardType` | `layout.archives.cardType` | 新增 |
|
||||||
|
| `avatarRadius` | `isAvatarRadius` | 评论头像是否圆形 |
|
||||||
|
|
||||||
|
> 后端新增字段建议统一命名 `{页面}ListLayout` / `{页面}CardType`;
|
||||||
|
> 列表布局值建议直接下发 `single/double`,旧值 `h_row_col1/h_row_col2` 前端会归一化兼容;
|
||||||
|
> 卡片样式值直接下发组件 layout 值(4 个 `image_*`)。
|
||||||
|
|
||||||
|
## 4. 消费方
|
||||||
|
|
||||||
|
| 位置 | 消费内容 |
|
||||||
|
|------|---------|
|
||||||
|
| `src/pages-blog/setting/setting.vue` | 顶部分段器(布局 / 功能);「布局」按页面分组设置「列表布局 / 卡片样式」 |
|
||||||
|
| `src/pages/tabbar/home/home.vue` | `layout.home.listLayout` 决定容器单/双列;卡片 `from="home"` 读 `layout.home.cardType` |
|
||||||
|
| `src/pages-blog/articles/articles.vue` | `layout.articles.listLayout` 决定容器与 `variant`(grid/list);卡片 `from="articles"` |
|
||||||
|
| `src/pages-blog/archives/archives.vue` | `layout.archives.listLayout` 决定归档时间线内单/双列;卡片 `from="archives"` |
|
||||||
|
| `src/components/uh-article-card/uh-article-card.vue` | 按 `from` 读取对应页面 `cardType` 作为 layout;grid/双列窄列自动回退 `image_top`;非法值兜底 `image_bottom` |
|
||||||
|
|
||||||
|
## 5. 设置页枚举(「默认」= 跟随站点默认)
|
||||||
|
|
||||||
|
- 列表布局:跟随站点默认 / 单列(`single`)/ 双列(`double`)
|
||||||
|
- 卡片样式:跟随站点默认 / 上图下文(`image_top`)/ 左文右图(`image_right`)/ 上文下图(`image_bottom`)/ 左图右文(`image_left`)
|
||||||
|
- 选择「跟随站点默认」时写入差异 `null`(删除本地覆盖),回退 L0 站点默认;无 L0 时回退内置默认。
|
||||||
|
|
||||||
|
## 6. 变更记录
|
||||||
|
|
||||||
|
| 版本 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| v2.4 | 卡片样式值体系由旧 `lr_*/tb_*` 改为组件 layout 值 `image_top/image_right/image_bottom/image_left`;内置默认 cardType 统一为 `image_bottom`;移除 `uh-article-card` 旧值映射(`CARD_TYPE_TO_LAYOUT`),非法值兜底 `image_bottom` |
|
||||||
|
| v2.3 | 布局偏好由全局 `layout.home` / `layout.cardType`(字符串)重构为按页面分组 `layout.{home,articles,archives}.{listLayout,cardType}`;设置页增加「布局 / 功能」分段器与页面分组设置项;三个消费页面联动卡片 |
|
||||||
@@ -117,11 +117,23 @@ export interface IAppConfig {
|
|||||||
auditConfig?: IAuditConfig
|
auditConfig?: IAuditConfig
|
||||||
/**
|
/**
|
||||||
* 站点级展示偏好默认(L0,插件端 GeneralConfig.preferences 经 getConfigs additive 下发;
|
* 站点级展示偏好默认(L0,插件端 GeneralConfig.preferences 经 getConfigs additive 下发;
|
||||||
* 客户端 layout.home/cardType/isAvatarRadius 的站点默认来源,本地偏好可覆盖)
|
* 客户端 layout.{home,articles,archives}.{listLayout,cardType}/isAvatarRadius 的站点默认来源,
|
||||||
|
* 本地偏好可覆盖;字段映射见 hermes/preferences.md §3)
|
||||||
*/
|
*/
|
||||||
preferences?: {
|
preferences?: {
|
||||||
|
/** 首页列表布局(h_row_col1/2 旧值由前端归一化为 single/double) */
|
||||||
homeListLayout?: string
|
homeListLayout?: string
|
||||||
|
/** 首页卡片样式(image_top/image_right/image_bottom/image_left) */
|
||||||
|
homeCardType?: string
|
||||||
|
/** 文章列表页列表布局 */
|
||||||
|
articlesListLayout?: string
|
||||||
|
/** 文章列表页卡片样式(沿用旧字段名,兼容既有下发) */
|
||||||
articleCardType?: string
|
articleCardType?: string
|
||||||
|
/** 文章归档页列表布局 */
|
||||||
|
archivesListLayout?: string
|
||||||
|
/** 文章归档页卡片样式 */
|
||||||
|
archivesCardType?: string
|
||||||
|
/** 评论头像是否圆形 */
|
||||||
avatarRadius?: boolean
|
avatarRadius?: boolean
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,14 +22,6 @@
|
|||||||
pinned: string
|
pinned: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 旧版全局 cardType → 新版 layout;未知值(如 only_text)由 effectiveLayout 兜底 image_top */
|
|
||||||
const CARD_TYPE_TO_LAYOUT: Record<string, CardLayout> = {
|
|
||||||
lr_image_text: 'image_left',
|
|
||||||
lr_text_image: 'image_right',
|
|
||||||
tb_image_text: 'image_top',
|
|
||||||
tb_text_image: 'image_bottom',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单一事实源:每种布局的完整形态,模板不再有任何 order / 条件分支 */
|
/** 单一事实源:每种布局的完整形态,模板不再有任何 order / 条件分支 */
|
||||||
const CARD_LAYOUTS: Record<CardLayout, CardLayoutClasses> = {
|
const CARD_LAYOUTS: Record<CardLayout, CardLayoutClasses> = {
|
||||||
image_top: {
|
image_top: {
|
||||||
@@ -54,7 +46,7 @@
|
|||||||
authorGroup: 'items-center gap-x-2',
|
authorGroup: 'items-center gap-x-2',
|
||||||
avatar: '!h-9 !w-9 !rounded-xl',
|
avatar: '!h-9 !w-9 !rounded-xl',
|
||||||
nickname: '!text-sm',
|
nickname: '!text-sm',
|
||||||
infoCol: 'flex-col items-start leading-tight',
|
infoCol: 'leading-tight',
|
||||||
time: '',
|
time: '',
|
||||||
tagCategory: '',
|
tagCategory: '',
|
||||||
visits: '',
|
visits: '',
|
||||||
@@ -106,23 +98,26 @@
|
|||||||
|
|
||||||
const isGrid = computed(() => props.variant === 'grid')
|
const isGrid = computed(() => props.variant === 'grid')
|
||||||
|
|
||||||
/** 实际生效布局:显式 layout > home/archives 跟随全局 cardType > image_top;窄列场景左右布局回退上图下文 */
|
/** 实际生效布局:显式 layout > 按页面读取全局 cardType(首页/文章列表/文章归档)> image_top;窄列场景左右布局回退上图下文 */
|
||||||
const effectiveLayout = computed<CardLayout>(() => {
|
const effectiveLayout = computed<CardLayout>(() => {
|
||||||
const followGlobal = props.from === 'home' || props.from === 'archives'
|
const _layout = settingStore.settings.layout
|
||||||
|
const page = props.from === 'home' || props.from === 'articles' || props.from === 'archives'
|
||||||
|
? props.from
|
||||||
|
: null
|
||||||
let raw = props.layout
|
let raw = props.layout
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
raw = followGlobal
|
raw = page
|
||||||
? CARD_TYPE_TO_LAYOUT[settingStore.settings.layout.cardType] ?? 'image_top'
|
? (_layout[page].cardType as CardLayout)
|
||||||
: 'image_top'
|
: 'image_top'
|
||||||
}
|
}
|
||||||
const narrow = isGrid.value || (props.from === 'home' && settingStore.settings.layout.home === 'h_row_col2')
|
const narrow = isGrid.value || (props.from === 'home' && _layout.home.listLayout === 'double')
|
||||||
if (narrow && (raw === 'image_left' || raw === 'image_right')) {
|
if (narrow && (raw === 'image_left' || raw === 'image_right')) {
|
||||||
return 'image_top'
|
return 'image_top'
|
||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
})
|
})
|
||||||
|
|
||||||
const cardLayout = computed(() => CARD_LAYOUTS[effectiveLayout.value])
|
const cardLayout = computed(() => CARD_LAYOUTS[effectiveLayout.value] ?? CARD_LAYOUTS.image_bottom)
|
||||||
|
|
||||||
/** 社交卡片形态(封面在下):左上用户信息(头像 + 昵称/日期垂直)、右上浏览数 */
|
/** 社交卡片形态(封面在下):左上用户信息(头像 + 昵称/日期垂直)、右上浏览数 */
|
||||||
const isSocialCard = computed(() => effectiveLayout.value === 'image_bottom')
|
const isSocialCard = computed(() => effectiveLayout.value === 'image_bottom')
|
||||||
@@ -192,8 +187,8 @@
|
|||||||
<image :src="article.owner.avatar" class="uh-global-card-glass h-5 w-5 rounded-full"
|
<image :src="article.owner.avatar" class="uh-global-card-glass h-5 w-5 rounded-full"
|
||||||
:class="cardLayout.avatar" mode="aspectFill" />
|
:class="cardLayout.avatar" mode="aspectFill" />
|
||||||
<template v-if="isSocialCard">
|
<template v-if="isSocialCard">
|
||||||
<view class="flex" :class="cardLayout.infoCol">
|
<view :class="cardLayout.infoCol">
|
||||||
<text class="truncate" :class="cardLayout.nickname">{{ article.owner.displayName }}</text>
|
<text class="block truncate" :class="cardLayout.nickname">{{ article.owner.displayName }}</text>
|
||||||
<view class="flex items-center gap-x-2">
|
<view class="flex items-center gap-x-2">
|
||||||
<text class="text-gray-400" :class="cardLayout.time">{{ publishTimeText }}</text>
|
<text class="text-gray-400" :class="cardLayout.time">{{ publishTimeText }}</text>
|
||||||
<view class="visits flex items-center gap-x-1 text-gray-400">
|
<view class="visits flex items-center gap-x-1 text-gray-400">
|
||||||
|
|||||||
@@ -200,8 +200,7 @@
|
|||||||
<text v-if="voteTypeLabel" class="rounded-md bg-primary px-2 py-0.5 text-xs text-gray-900">
|
<text v-if="voteTypeLabel" class="rounded-md bg-primary px-2 py-0.5 text-xs text-gray-900">
|
||||||
{{ voteTypeLabel }}
|
{{ voteTypeLabel }}
|
||||||
</text>
|
</text>
|
||||||
<text v-if="voteState" class="rounded-md px-2 py-0.5 text-xs"
|
<text v-if="voteState" class="rounded-md px-2 py-0.5 text-xs" :class="[voteState.color,voteState.bgColor]">
|
||||||
:style="{ color: voteState.color, backgroundColor: `${voteState.color}1a` }">
|
|
||||||
{{ voteState.state }}
|
{{ voteState.state }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface IVoteCardData {
|
|||||||
endDate?: string
|
endDate?: string
|
||||||
isVoted?: boolean
|
isVoted?: boolean
|
||||||
_uh_type?: string
|
_uh_type?: string
|
||||||
_uh_state?: { state: string, color: string }
|
_uh_state?: { state: string, color: string, bgColor: string }
|
||||||
options?: IVoteCardOption[]
|
options?: IVoteCardOption[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ function formatTime(date?: string, fmt = 'yyyy-MM-dd HH:mm'): string {
|
|||||||
<text
|
<text
|
||||||
v-if="vote.spec?._uh_state"
|
v-if="vote.spec?._uh_state"
|
||||||
class="rounded-md px-1.5 py-0.5 text-[22rpx]"
|
class="rounded-md px-1.5 py-0.5 text-[22rpx]"
|
||||||
:style="{ color: vote.spec._uh_state.color, backgroundColor: `${vote.spec._uh_state.color}1a` }"
|
:class="[vote.spec._uh_state.color, vote.spec._uh_state.bgColor]"
|
||||||
>
|
>
|
||||||
{{ vote.spec._uh_state.state }}
|
{{ vote.spec._uh_state.state }}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
* 应用设置默认值与类型(源自旧项目 utils/app.js 的 _DefaultAppSettings)
|
* 应用设置默认值与类型(源自旧项目 utils/app.js 的 _DefaultAppSettings)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 单页布局偏好(列表布局 + 卡片样式,与后端 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 {
|
||||||
/** 评论头像是否圆形 */
|
/** 评论头像是否圆形 */
|
||||||
isAvatarRadius: boolean
|
isAvatarRadius: boolean
|
||||||
@@ -9,12 +17,14 @@ export interface IAppSettings {
|
|||||||
useDot: boolean
|
useDot: boolean
|
||||||
dotPosition: string
|
dotPosition: string
|
||||||
}
|
}
|
||||||
/** 布局配置 */
|
/** 布局配置(按页面分组,每组可独立自定义) */
|
||||||
layout: {
|
layout: {
|
||||||
/** h_row_col1 = 一行一列 / h_row_col2 = 一行两列 */
|
/** 首页 */
|
||||||
home: string
|
home: IPageLayoutPref
|
||||||
/** lr_image_text=左图右文 / lr_text_image=左文右图 / tb_image_text=上图下文 / tb_text_image=上文下图 / only_text=仅文字 */
|
/** 文章列表页 */
|
||||||
cardType: string
|
articles: IPageLayoutPref
|
||||||
|
/** 文章归档页 */
|
||||||
|
archives: IPageLayoutPref
|
||||||
}
|
}
|
||||||
/** 广告配置 */
|
/** 广告配置 */
|
||||||
ad: {
|
ad: {
|
||||||
@@ -53,8 +63,9 @@ export const DefaultAppSettings: IAppSettings = {
|
|||||||
dotPosition: 'right',
|
dotPosition: 'right',
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
home: 'h_row_col1',
|
home: { listLayout: 'single', cardType: 'image_bottom' },
|
||||||
cardType: 'lr_image_text',
|
articles: { listLayout: 'double', cardType: 'image_bottom' },
|
||||||
|
archives: { listLayout: 'single', cardType: 'image_bottom' },
|
||||||
},
|
},
|
||||||
ad: {
|
ad: {
|
||||||
timeout: 3,
|
timeout: 3,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
import { getPostList } from '@/api/halo'
|
import { getPostList } from '@/api/halo'
|
||||||
import { useAppConfigStore } from '@/store/appConfig'
|
import { useAppConfigStore } from '@/store/appConfig'
|
||||||
|
import { useSettingStore } from '@/store/setting'
|
||||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||||
import { sleep } from '@/utils/common'
|
import { sleep } from '@/utils/common'
|
||||||
import type { IPost } from '@/api/types/halo'
|
import type { IPost } from '@/api/types/halo'
|
||||||
@@ -19,6 +20,11 @@ const appConfigStore = useAppConfigStore()
|
|||||||
|
|
||||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||||
|
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
|
/** 归档页列表布局(偏好设置驱动:single=单列 / double=双列) */
|
||||||
|
const archivesListLayout = computed(() => settingStore.settings.layout.archives.listLayout)
|
||||||
|
|
||||||
/* ---------------- 状态 ---------------- */
|
/* ---------------- 状态 ---------------- */
|
||||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||||
const activeTabIndex = ref(0)
|
const activeTabIndex = ref(0)
|
||||||
@@ -270,7 +276,8 @@ onReachBottom(() => {
|
|||||||
<text class="rounded-full bg-secondary px-2 py-1 text-xs text-gray-500 leading-none">共 {{ item.posts.length }} 篇{{ calcAuditModeEnabled ? '内容' : '文章' }}</text>
|
<text class="rounded-full bg-secondary px-2 py-1 text-xs text-gray-500 leading-none">共 {{ item.posts.length }} 篇{{ calcAuditModeEnabled ? '内容' : '文章' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="item.posts.length !== 0" class="flex flex-col gap-y-4">
|
<view v-if="item.posts.length !== 0"
|
||||||
|
:class="archivesListLayout === 'double' ? 'grid grid-cols-2 gap-3' : 'flex flex-col gap-y-4'">
|
||||||
<uh-article-card
|
<uh-article-card
|
||||||
v-for="post in item.posts"
|
v-for="post in item.posts"
|
||||||
:key="post.metadata.name"
|
:key="post.metadata.name"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
import { getCategoryList, getPostList } from '@/api/halo'
|
import { getCategoryList, getPostList } from '@/api/halo'
|
||||||
import { useAppConfigStore } from '@/store/appConfig'
|
import { useAppConfigStore } from '@/store/appConfig'
|
||||||
|
import { useSettingStore } from '@/store/setting'
|
||||||
import { checkAvatarUrl } from '@/utils/url'
|
import { checkAvatarUrl } from '@/utils/url'
|
||||||
import { t } from '@/locale'
|
import { t } from '@/locale'
|
||||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||||
@@ -20,6 +21,11 @@ definePage({
|
|||||||
const appConfigStore = useAppConfigStore()
|
const appConfigStore = useAppConfigStore()
|
||||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||||
|
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
|
/** 文章列表页列表布局(偏好设置驱动:single=单列 / double=双列) */
|
||||||
|
const articlesListLayout = computed(() => settingStore.settings.layout.articles.listLayout)
|
||||||
|
|
||||||
/* ---------------- 状态 ---------------- */
|
/* ---------------- 状态 ---------------- */
|
||||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||||
const articleList = ref<IPost[]>([])
|
const articleList = ref<IPost[]>([])
|
||||||
@@ -208,12 +214,12 @@ onReachBottom(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<view v-else class="box-border flex flex-col gap-4 p-3">
|
<view v-else class="box-border flex flex-col gap-4 p-3">
|
||||||
<view class="grid grid-cols-2 gap-3">
|
<view :class="articlesListLayout === 'double' ? 'grid grid-cols-2 gap-3' : 'flex flex-col gap-3'">
|
||||||
<uh-article-card
|
<uh-article-card
|
||||||
v-for="(article, index) in articleList"
|
v-for="(article, index) in articleList"
|
||||||
:key="article.metadata.name || index"
|
:key="article.metadata.name || index"
|
||||||
from="articles"
|
from="articles"
|
||||||
variant="grid"
|
:variant="articlesListLayout === 'double' ? 'grid' : 'list'"
|
||||||
:article="article"
|
:article="article"
|
||||||
:audit-mode="calcAuditModeEnabled"
|
:audit-mode="calcAuditModeEnabled"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -56,36 +56,49 @@
|
|||||||
siteLabelOf ?: (value : string) => string
|
siteLabelOf ?: (value : string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutPrefs : PrefDef[] = [
|
/** 布局设置按页面分组(每组:列表布局 + 卡片样式) */
|
||||||
{
|
const PAGE_GROUPS = [
|
||||||
key: 'home',
|
{ key: 'home', label: '首页' },
|
||||||
label: '首页文章布局',
|
{ key: 'articles', label: '文章列表' },
|
||||||
kind: 'enum',
|
{ key: 'archives', label: '文章归档' },
|
||||||
path: ['layout', 'home'],
|
|
||||||
options: [
|
|
||||||
{ label: '一行一列', value: 'h_row_col1' },
|
|
||||||
{ label: '一行两列', value: 'h_row_col2' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cardType',
|
|
||||||
label: '文章卡片样式',
|
|
||||||
kind: 'enum',
|
|
||||||
path: ['layout', 'cardType'],
|
|
||||||
options: [
|
|
||||||
{ label: '左图右文', value: 'lr_image_text' },
|
|
||||||
{ label: '左文右图', value: 'lr_text_image' },
|
|
||||||
{ label: '上图下文', value: 'tb_image_text' },
|
|
||||||
{ label: '上文下图', value: 'tb_text_image' },
|
|
||||||
{ label: '只有文字', value: 'only_text' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const layoutPrefs : PrefDef[] = PAGE_GROUPS.flatMap(group => [
|
||||||
|
{
|
||||||
|
key: `${group.key}ListLayout`,
|
||||||
|
label: '列表布局',
|
||||||
|
kind: 'enum',
|
||||||
|
path: ['layout', group.key, 'listLayout'],
|
||||||
|
options: [
|
||||||
|
{ label: '单列', value: 'single' },
|
||||||
|
{ label: '双列', value: 'double' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `${group.key}CardType`,
|
||||||
|
label: '卡片样式',
|
||||||
|
kind: 'enum',
|
||||||
|
path: ['layout', group.key, 'cardType'],
|
||||||
|
options: [
|
||||||
|
{ label: '上图下文', value: 'image_top' },
|
||||||
|
{ label: '左文右图', value: 'image_right' },
|
||||||
|
{ label: '上文下图', value: 'image_bottom' },
|
||||||
|
{ label: '左图右文', value: 'image_left' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const featurePrefs : PrefDef[] = [
|
const featurePrefs : PrefDef[] = [
|
||||||
{ key: 'isAvatarRadius', label: '是否圆形头像', kind: 'bool', path: ['isAvatarRadius'] },
|
{ key: 'isAvatarRadius', label: '是否圆形头像', kind: 'bool', path: ['isAvatarRadius'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/* ---------------- 顶部分段器(布局 / 功能) ---------------- */
|
||||||
|
const settingTabs : { key : 'layout' | 'feature', label : string }[] = [
|
||||||
|
{ key: 'layout', label: '布局' },
|
||||||
|
{ key: 'feature', label: '功能' },
|
||||||
|
]
|
||||||
|
const activeTab = ref<'layout' | 'feature'>('layout')
|
||||||
|
|
||||||
/* ---------------- 状态读取 ---------------- */
|
/* ---------------- 状态读取 ---------------- */
|
||||||
function prefValueOf(path : Path) : unknown {
|
function prefValueOf(path : Path) : unknown {
|
||||||
return getByPath(settingStore.settings, path)
|
return getByPath(settingStore.settings, path)
|
||||||
@@ -216,6 +229,13 @@
|
|||||||
const layoutRows = computed(() => buildRows(layoutPrefs))
|
const layoutRows = computed(() => buildRows(layoutPrefs))
|
||||||
const featureRows = computed(() => buildRows(featurePrefs))
|
const featureRows = computed(() => buildRows(featurePrefs))
|
||||||
|
|
||||||
|
/** 布局设置按页面分组的展示行 */
|
||||||
|
const layoutGroups = computed(() => PAGE_GROUPS.map(group => ({
|
||||||
|
key: group.key,
|
||||||
|
label: group.label,
|
||||||
|
rows: layoutRows.value.filter(row => row.path[1] === group.key),
|
||||||
|
})))
|
||||||
|
|
||||||
/* ---------------- 重置全部 ---------------- */
|
/* ---------------- 重置全部 ---------------- */
|
||||||
function handleResetAll() {
|
function handleResetAll() {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
@@ -243,45 +263,54 @@
|
|||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<view class="box-border flex flex-col gap-y-6 p-3">
|
<view class="box-border flex flex-col gap-y-6 p-3">
|
||||||
<!-- 布局设置 -->
|
<!-- 顶部分段器:布局 / 功能 -->
|
||||||
<view class="flex flex-col gap-y-3">
|
<view class="uh-global-card-glass flex rounded-full p-1">
|
||||||
<uh-section-title>
|
<view v-for="tab in settingTabs" :key="tab.key"
|
||||||
布局
|
class="flex-1 rounded-full py-1.5 text-center text-sm"
|
||||||
<template #right>
|
:class="activeTab === tab.key ? 'bg-primary font-bold' : 'text-gray-500'"
|
||||||
<text class="text-2xs text-gray-400">应用以及文章列表布局设置</text>
|
@click="activeTab = tab.key">
|
||||||
</template>
|
{{ tab.label }}
|
||||||
</uh-section-title>
|
</view>
|
||||||
<view class="uh-global-card-glass overflow-hidden rounded-2xl">
|
</view>
|
||||||
<view v-for="(row, index) in layoutRows" :key="row.key"
|
|
||||||
class="pick-row flex items-center justify-between px-4 py-4"
|
<!-- 布局:按页面分组(首页/文章列表/文章归档 × 列表布局/卡片样式) -->
|
||||||
:class="index < layoutRows.length - 1 ? 'border-b border-black/5' : ''"
|
<template v-if="activeTab === 'layout'">
|
||||||
@click="handleOpenEnum(row)">
|
<view v-for="group in layoutGroups" :key="group.key" class="flex flex-col gap-y-3">
|
||||||
<view class="row-left flex flex-col gap-1">
|
<uh-section-title>{{ group.label }}</uh-section-title>
|
||||||
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ row.label }}</text>
|
<view class="uh-global-card-glass overflow-hidden rounded-2xl">
|
||||||
<view class="flex items-center gap-2">
|
<view v-for="(row, index) in group.rows" :key="row.key"
|
||||||
<text v-if="row.following" class="row-sub text-2xs text-gray-400">跟随站点默认</text>
|
class="pick-row flex items-center justify-between px-4 py-4"
|
||||||
<view v-else
|
:class="index < group.rows.length - 1 ? 'border-b border-black/5' : ''"
|
||||||
class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
|
@click="handleOpenEnum(row)">
|
||||||
已自定义
|
<view class="row-left flex flex-col gap-1">
|
||||||
|
<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-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
|
||||||
|
已自定义
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<view class="row-value flex items-center gap-2">
|
||||||
<view class="row-value flex items-center gap-2">
|
<text class="value-text text-[26rpx] text-gray-400">{{ row.displayValue }}</text>
|
||||||
<text class="value-text text-[26rpx] text-gray-400">{{ row.displayValue }}</text>
|
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
|
||||||
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</template>
|
||||||
|
|
||||||
<!-- 功能设置 -->
|
<!-- 功能设置 -->
|
||||||
<view class="flex flex-col gap-y-3">
|
<template v-else>
|
||||||
<uh-section-title>
|
<view class="flex flex-col gap-y-3">
|
||||||
功能
|
<uh-section-title>
|
||||||
<template #right>
|
功能
|
||||||
<text class="text-2xs text-gray-400">一些常用的功能性设置</text>
|
<template #right>
|
||||||
</template>
|
<text class="text-2xs text-gray-400">一些常用的功能性设置</text>
|
||||||
</uh-section-title>
|
</template>
|
||||||
<view class="setting-sheet uh-global-card-glass overflow-hidden rounded-2xl">
|
</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">
|
<template v-for="(row, index) in featureRows" :key="row.key">
|
||||||
<!-- 布尔开关 -->
|
<!-- 布尔开关 -->
|
||||||
<view v-if="row.kind === 'bool'" class="switch-row flex items-center justify-between px-4 py-4"
|
<view v-if="row.kind === 'bool'" class="switch-row flex items-center justify-between px-4 py-4"
|
||||||
@@ -332,7 +361,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 底部操作栏-->
|
</template>
|
||||||
|
<!-- 底部操作栏-->
|
||||||
<view class="box-border w-full">
|
<view class="box-border w-full">
|
||||||
<uh-button custom-class="uh-global-card-glass py-2 !rounded-xl" @click="handleResetAll">
|
<uh-button custom-class="uh-global-card-glass py-2 !rounded-xl" @click="handleResetAll">
|
||||||
恢复默认
|
恢复默认
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const vote = ref<(IVote & {
|
|||||||
hasEnded?: boolean
|
hasEnded?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
_uh_type?: string
|
_uh_type?: string
|
||||||
_uh_state?: { state: string, color: string }
|
_uh_state?: { state: string, color: string, bgColor: string }
|
||||||
}
|
}
|
||||||
stats?: { voteCount?: number }
|
stats?: { voteCount?: number }
|
||||||
}) | null>(null)
|
}) | null>(null)
|
||||||
@@ -283,7 +283,7 @@ onShareTimeline(() => ({
|
|||||||
<text>投票状态:</text>
|
<text>投票状态:</text>
|
||||||
<text
|
<text
|
||||||
class="tag"
|
class="tag"
|
||||||
:style="{ color: vote.spec?._uh_state?.color }"
|
:class="[vote.spec?._uh_state?.color, vote.spec?._uh_state?.bgColor]"
|
||||||
>
|
>
|
||||||
{{ vote.spec?._uh_state?.state }}
|
{{ vote.spec?._uh_state?.state }}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -93,6 +93,9 @@
|
|||||||
|
|
||||||
const globalAppSettings = computed(() => settingStore.settings)
|
const globalAppSettings = computed(() => settingStore.settings)
|
||||||
|
|
||||||
|
/** 首页列表布局(偏好设置驱动:single=单列 / double=双列) */
|
||||||
|
const homeListLayout = computed(() => settingStore.settings.layout.home.listLayout)
|
||||||
|
|
||||||
/* ---------------- 数据加载 ---------------- */
|
/* ---------------- 数据加载 ---------------- */
|
||||||
async function handleQuery() {
|
async function handleQuery() {
|
||||||
handleGetArticleList()
|
handleGetArticleList()
|
||||||
@@ -243,9 +246,10 @@
|
|||||||
min-height="36vh" @refresh="handleQuery" />
|
min-height="36vh" @refresh="handleQuery" />
|
||||||
|
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<view class="box-border flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
|
<view class="box-border p-3 pt-0"
|
||||||
|
:class="homeListLayout === 'double' ? 'grid grid-cols-2 gap-3' : 'flex flex-col gap-y-3'">
|
||||||
<uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
|
<uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
|
||||||
:audit-mode="calcAuditModeEnabled" layout="image_bottom"/>
|
:audit-mode="calcAuditModeEnabled" />
|
||||||
</view>
|
</view>
|
||||||
<view class="mt-3 box-border pb-5 text-center text-xs text-gray-400">
|
<view class="mt-3 box-border pb-5 text-center text-xs text-gray-400">
|
||||||
{{ loadMoreText }}
|
{{ loadMoreText }}
|
||||||
|
|||||||
@@ -36,28 +36,28 @@ describe('preference 基础读写', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('updateLocalPrefs:嵌套字段增量合并', () => {
|
it('updateLocalPrefs:嵌套字段增量合并', () => {
|
||||||
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
|
updateLocalPrefs({ layout: { home: { listLayout: 'double' } } })
|
||||||
updateLocalPrefs({ layout: { cardType: 'tb_image_text' } })
|
updateLocalPrefs({ layout: { home: { cardType: 'image_bottom' } } })
|
||||||
expect(readLocalPrefs()).toEqual({
|
expect(readLocalPrefs()).toEqual({
|
||||||
layout: { home: 'h_row_col2', cardType: 'tb_image_text' },
|
layout: { home: { listLayout: 'double', cardType: 'image_bottom' } },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updateLocalPrefs:null 删除该键(回退跟随站点默认)', () => {
|
it('updateLocalPrefs:null 删除该键(回退跟随站点默认)', () => {
|
||||||
updateLocalPrefs({ layout: { home: 'h_row_col2', cardType: 'tb_image_text' } })
|
updateLocalPrefs({ layout: { home: { listLayout: 'double', cardType: 'image_bottom' } } })
|
||||||
updateLocalPrefs({ layout: { home: null } })
|
updateLocalPrefs({ layout: { home: null } })
|
||||||
expect(readLocalPrefs()).toEqual({ layout: { cardType: 'tb_image_text' } })
|
expect(readLocalPrefs()).toEqual({ layout: {} })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updateLocalPrefs(null):整体清空差异', () => {
|
it('updateLocalPrefs(null):整体清空差异', () => {
|
||||||
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
|
updateLocalPrefs({ layout: { home: { listLayout: '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: 'h_row_col2' } })
|
updateLocalPrefs({ layout: { home: { listLayout: 'double' } } })
|
||||||
clearLocalPrefs()
|
clearLocalPrefs()
|
||||||
expect(readLocalPrefs()).toEqual({})
|
expect(readLocalPrefs()).toEqual({})
|
||||||
})
|
})
|
||||||
@@ -76,10 +76,10 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
|||||||
|
|
||||||
it('本地优先于站点默认,站点默认优先于内置默认', () => {
|
it('本地优先于站点默认,站点默认优先于内置默认', () => {
|
||||||
const merged = mergeWithDefaults(
|
const merged = mergeWithDefaults(
|
||||||
{ layout: { home: 'h_row_col1' }, gallery: { useWaterfull: true } },
|
{ layout: { home: { listLayout: 'single' } }, gallery: { useWaterfull: true } },
|
||||||
{ layout: { home: 'h_row_col2' } },
|
{ layout: { home: { listLayout: 'double' } } },
|
||||||
)
|
)
|
||||||
expect(merged.layout.home).toBe('h_row_col2')
|
expect(merged.layout.home.listLayout).toBe('double')
|
||||||
expect(merged.gallery.useWaterfull).toBe(true)
|
expect(merged.gallery.useWaterfull).toBe(true)
|
||||||
expect(merged.isAvatarRadius).toBe(DefaultAppSettings.isAvatarRadius)
|
expect(merged.isAvatarRadius).toBe(DefaultAppSettings.isAvatarRadius)
|
||||||
})
|
})
|
||||||
@@ -95,15 +95,22 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
|||||||
expect(site.banner).toEqual({ useDot: false, dotPosition: 'bottom' })
|
expect(site.banner).toEqual({ useDot: false, dotPosition: 'bottom' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collectSiteDefaults:preferences(L0)映射到 layout.home/cardType/isAvatarRadius', () => {
|
it('collectSiteDefaults:preferences(L0)映射到 layout 页面分组/isAvatarRadius', () => {
|
||||||
const site = collectSiteDefaults({
|
const site = collectSiteDefaults({
|
||||||
preferences: {
|
preferences: {
|
||||||
homeListLayout: 'h_row_col2',
|
homeListLayout: 'h_row_col2',
|
||||||
articleCardType: 'tb_image_text',
|
homeCardType: 'image_bottom',
|
||||||
|
articlesListLayout: 'single',
|
||||||
|
articleCardType: 'image_left',
|
||||||
|
archivesCardType: 'image_top',
|
||||||
avatarRadius: true,
|
avatarRadius: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(site.layout).toEqual({ home: 'h_row_col2', cardType: 'tb_image_text' })
|
expect(site.layout).toEqual({
|
||||||
|
home: { listLayout: 'double', cardType: 'image_bottom' },
|
||||||
|
articles: { listLayout: 'single', cardType: 'image_left' },
|
||||||
|
archives: { cardType: 'image_top' },
|
||||||
|
})
|
||||||
expect(site.isAvatarRadius).toBe(true)
|
expect(site.isAvatarRadius).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -111,13 +118,13 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
|||||||
const site = collectSiteDefaults({
|
const site = collectSiteDefaults({
|
||||||
preferences: {
|
preferences: {
|
||||||
homeListLayout: 'h_row_col2',
|
homeListLayout: 'h_row_col2',
|
||||||
articleCardType: 'tb_image_text',
|
articleCardType: 'image_bottom',
|
||||||
avatarRadius: true,
|
avatarRadius: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const merged = mergeWithDefaults(site, {})
|
const merged = mergeWithDefaults(site, {})
|
||||||
expect(merged.layout.home).toBe('h_row_col2')
|
expect(merged.layout.home.listLayout).toBe('double')
|
||||||
expect(merged.layout.cardType).toBe('tb_image_text')
|
expect(merged.layout.articles.cardType).toBe('image_bottom')
|
||||||
expect(merged.isAvatarRadius).toBe(true)
|
expect(merged.isAvatarRadius).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,13 +132,13 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
|||||||
const site = collectSiteDefaults({
|
const site = collectSiteDefaults({
|
||||||
preferences: {
|
preferences: {
|
||||||
homeListLayout: 'h_row_col2',
|
homeListLayout: 'h_row_col2',
|
||||||
articleCardType: 'tb_image_text',
|
articleCardType: 'image_bottom',
|
||||||
avatarRadius: true,
|
avatarRadius: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const merged = mergeWithDefaults(site, { layout: { home: 'h_row_col1' }, isAvatarRadius: false })
|
const merged = mergeWithDefaults(site, { layout: { home: { listLayout: 'single' } }, isAvatarRadius: false })
|
||||||
expect(merged.layout.home).toBe('h_row_col1')
|
expect(merged.layout.home.listLayout).toBe('single')
|
||||||
expect(merged.layout.cardType).toBe('tb_image_text')
|
expect(merged.layout.articles.cardType).toBe('image_bottom')
|
||||||
expect(merged.isAvatarRadius).toBe(false)
|
expect(merged.isAvatarRadius).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -146,12 +153,12 @@ describe('mergeWithDefaults / collectSiteDefaults', () => {
|
|||||||
const merged = mergeWithDefaults(site, {})
|
const merged = mergeWithDefaults(site, {})
|
||||||
expect(merged.banner.useDot).toBe(false)
|
expect(merged.banner.useDot).toBe(false)
|
||||||
expect(merged.banner.dotPosition).toBe('bottom')
|
expect(merged.banner.dotPosition).toBe('bottom')
|
||||||
expect(merged.layout.home).toBe(DefaultAppSettings.layout.home)
|
expect(merged.layout.home.listLayout).toBe(DefaultAppSettings.layout.home.listLayout)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('未知枚举值不回退抛错(跟随默认)', () => {
|
it('未知枚举值不回退抛错(跟随默认)', () => {
|
||||||
const merged = mergeWithDefaults({}, { layout: { home: 'not-exist' } })
|
const merged = mergeWithDefaults({}, { layout: { home: { listLayout: 'not-exist' } } })
|
||||||
expect(merged.layout.home).toBe('not-exist')
|
expect(merged.layout.home.listLayout).toBe('not-exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -163,7 +170,8 @@ describe('migrateLegacyLocalPrefs', () => {
|
|||||||
|
|
||||||
it('旧 persist 存在时仅迁移被改过的叶子字段', () => {
|
it('旧 persist 存在时仅迁移被改过的叶子字段', () => {
|
||||||
const legacySettings: IAppSettings = JSON.parse(JSON.stringify(DefaultAppSettings))
|
const legacySettings: IAppSettings = JSON.parse(JSON.stringify(DefaultAppSettings))
|
||||||
legacySettings.layout.home = 'h_row_col2'
|
// 旧结构 layout.home 为 string(列表布局),类型上绕过新结构约束
|
||||||
|
;(legacySettings.layout as unknown as Record<string, unknown>).home = 'h_row_col2'
|
||||||
legacySettings.gallery.useWaterfull = false
|
legacySettings.gallery.useWaterfull = false
|
||||||
mem.set('setting', JSON.stringify({ settings: legacySettings }))
|
mem.set('setting', JSON.stringify({ settings: legacySettings }))
|
||||||
|
|
||||||
@@ -175,13 +183,13 @@ describe('migrateLegacyLocalPrefs', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('已存在新差异键时不再重复迁移', () => {
|
it('已存在新差异键时不再重复迁移', () => {
|
||||||
updateLocalPrefs({ layout: { home: 'h_row_col1' } })
|
updateLocalPrefs({ layout: { home: { listLayout: 'single' } } })
|
||||||
const legacySettings = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings
|
const legacySettings = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings
|
||||||
legacySettings.layout.home = 'h_row_col2'
|
;(legacySettings.layout as unknown as Record<string, unknown>).home = 'h_row_col2'
|
||||||
mem.set('setting', JSON.stringify({ settings: legacySettings }))
|
mem.set('setting', JSON.stringify({ settings: legacySettings }))
|
||||||
|
|
||||||
expect(migrateLegacyLocalPrefs()).toBe(false)
|
expect(migrateLegacyLocalPrefs()).toBe(false)
|
||||||
expect(readLocalPrefs()).toEqual({ layout: { home: 'h_row_col1' } })
|
expect(readLocalPrefs()).toEqual({ layout: { home: { listLayout: 'single' } } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('无旧键或格式异常时返回 false 且不写新键', () => {
|
it('无旧键或格式异常时返回 false 且不写新键', () => {
|
||||||
|
|||||||
+29
-8
@@ -57,8 +57,10 @@ export function clearLocalPrefs(): void {
|
|||||||
/**
|
/**
|
||||||
* 把 L0 站点默认(getConfigs 下发值)中与偏好相关的字段收集为本地差异形状的站点默认。
|
* 把 L0 站点默认(getConfigs 下发值)中与偏好相关的字段收集为本地差异形状的站点默认。
|
||||||
* 偏好字段与 getConfigs 字段不完全同名,此处维护映射表:
|
* 偏好字段与 getConfigs 字段不完全同名,此处维护映射表:
|
||||||
* - preferences.homeListLayout / articleCardType / avatarRadius(插件「通用配置-偏好设置」
|
* - preferences.homeListLayout/homeCardType/articlesListLayout/articleCardType/
|
||||||
* 分区,L0 additive 顶层键)→ layout.home / layout.cardType / isAvatarRadius;
|
* 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。
|
* - pageConfig.homeConfig.bannerConfig → banner.useDot / dotPosition。
|
||||||
*/
|
*/
|
||||||
export function collectSiteDefaults(configs: Partial<IAppConfig>): LocalPrefs {
|
export function collectSiteDefaults(configs: Partial<IAppConfig>): LocalPrefs {
|
||||||
@@ -67,14 +69,33 @@ export function collectSiteDefaults(configs: Partial<IAppConfig>): LocalPrefs {
|
|||||||
// 站点级展示偏好默认(L0,GeneralConfig.preferences,2026-09-02 插件端新增)
|
// 站点级展示偏好默认(L0,GeneralConfig.preferences,2026-09-02 插件端新增)
|
||||||
const preferences = configs.preferences
|
const preferences = configs.preferences
|
||||||
if (preferences && typeof preferences === 'object') {
|
if (preferences && typeof preferences === 'object') {
|
||||||
if (preferences.homeListLayout) {
|
const prefs = preferences as Record<string, unknown>
|
||||||
result.layout = { ...result.layout, home: preferences.homeListLayout }
|
/** 列表布局旧值归一化:h_row_col1/2 → single/double */
|
||||||
|
const listLayoutOf = (v: unknown) => {
|
||||||
|
if (typeof v !== 'string')
|
||||||
|
return undefined
|
||||||
|
return v === 'h_row_col2' ? 'double' : v === 'h_row_col1' ? 'single' : v
|
||||||
}
|
}
|
||||||
if (preferences.articleCardType) {
|
/** 页面布局:后端字段名(列表布局 + 卡片样式)→ 本地嵌套路径 */
|
||||||
result.layout = { ...result.layout, cardType: preferences.articleCardType }
|
const setPage = (page: 'home' | 'articles' | 'archives', listKey: string, cardKey: string) => {
|
||||||
|
const listValue = listLayoutOf(prefs[listKey])
|
||||||
|
const cardValue = typeof prefs[cardKey] === 'string' ? prefs[cardKey] : undefined
|
||||||
|
if (listValue === undefined && cardValue === undefined)
|
||||||
|
return
|
||||||
|
result.layout = {
|
||||||
|
...result.layout,
|
||||||
|
[page]: {
|
||||||
|
...(listValue !== undefined ? { listLayout: listValue } : {}),
|
||||||
|
...(cardValue !== undefined ? { cardType: cardValue } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (typeof preferences.avatarRadius === 'boolean') {
|
setPage('home', 'homeListLayout', 'homeCardType')
|
||||||
result.isAvatarRadius = preferences.avatarRadius
|
setPage('articles', 'articlesListLayout', 'articleCardType')
|
||||||
|
setPage('archives', 'archivesListLayout', 'archivesCardType')
|
||||||
|
|
||||||
|
if (typeof prefs.avatarRadius === 'boolean') {
|
||||||
|
result.isAvatarRadius = prefs.avatarRadius
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -25,11 +25,11 @@ export const VOTE_STATES: { NOT_VOTED: VoteState; VOTING: VoteState; VOTED: Vote
|
|||||||
VOTE_ENDED: 'vote-ended'
|
VOTE_ENDED: 'vote-ended'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 投票展示状态(与旧项目 VOTE_STATES 一致:中文 + 颜色) */
|
/** 投票展示状态(与旧项目 VOTE_STATES 一致:中文 + unocss 文字/背景色类) */
|
||||||
export const VOTE_STATE_LABELS: Record<string, { state: string; color: string }> = {
|
export const VOTE_STATE_LABELS: Record<string, { state: string; color: string; bgColor: string }> = {
|
||||||
未开始: { state: '未开始', color: 'orange' },
|
未开始: { state: '未开始', color: 'text-orange-400', bgColor: 'bg-orange-100' },
|
||||||
进行中: { state: '进行中', color: 'green' },
|
进行中: { state: '进行中', color: 'text-green-400', bgColor: 'bg-green-100' },
|
||||||
已结束: { state: '已结束', color: 'red' }
|
已结束: { state: '已结束', color: 'text-red-400', bgColor: 'bg-red-100' }
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,11 +48,12 @@ export function getOrCreateVoteUid(): string {
|
|||||||
* 计算投票展示状态(与旧项目 calcVoteState 一致)
|
* 计算投票展示状态(与旧项目 calcVoteState 一致)
|
||||||
* 非 custom 期限(permanent 等)直接看 hasEnded;custom 按起止时间判断
|
* 非 custom 期限(permanent 等)直接看 hasEnded;custom 按起止时间判断
|
||||||
* @param vote 投票对象(含 spec.timeLimit/hasEnded/startDate/endDate)
|
* @param vote 投票对象(含 spec.timeLimit/hasEnded/startDate/endDate)
|
||||||
* @returns { state: '未开始' | '进行中' | '已结束', color: 'orange' | 'green' | 'red' }
|
* @returns { state: '未开始' | '进行中' | '已结束', color: unocss 文字色类, bgColor: unocss 背景色类 }
|
||||||
*/
|
*/
|
||||||
export function calcVoteState(vote: { spec?: { timeLimit?: string; hasEnded?: boolean; startDate?: string; endDate?: string; [key: string]: unknown } }): {
|
export function calcVoteState(vote: { spec?: { timeLimit?: string; hasEnded?: boolean; startDate?: string; endDate?: string; [key: string]: unknown } }): {
|
||||||
state: string;
|
state: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
} {
|
} {
|
||||||
if (vote.spec?.timeLimit !== 'custom') {
|
if (vote.spec?.timeLimit !== 'custom') {
|
||||||
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中'];
|
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中'];
|
||||||
|
|||||||
Reference in New Issue
Block a user