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