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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
恢复默认
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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('updateLocalPrefs:null 删除该键(回退跟随站点默认)', () => {
|
||||
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('collectSiteDefaults:preferences(L0)映射到 layout.home/cardType/isAvatarRadius', () => {
|
||||
it('collectSiteDefaults:preferences(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
@@ -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
@@ -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['进行中'];
|
||||
|
||||
Reference in New Issue
Block a user