1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-09-11 16:00:44 +08:00

refactor: 整体项目样式与组件优化升级

主要变更:
1. 新增uhemoji2图标字体库并全局引入
2. 重构导航组件与页面布局,统一使用自定义导航栏
3. 替换旧图标为emoji图标,统一视觉风格
4. 清理冗余调试代码与注释
5. 优化快捷导航、关于页等页面样式与交互
6. 修复滚动到顶部组件黑名单配置
This commit is contained in:
小莫唐尼
2026-09-04 18:54:34 +08:00
parent 62b604644c
commit 56ce29e69e
15 changed files with 1534 additions and 1440 deletions
+81 -3
View File
@@ -295,7 +295,85 @@ onLoad(() => {
- 页面根节点用 `app-page` 类 + 主题底色:`<view class="app-page min-h-screen w-screen flex flex-col bg-page">`
- 页面标题 `navigationBarTitleText` 写中文;下拉刷新 `enablePullDownRefresh: true`
### 5.4 页面数据请求(统一 updateLoadingStatus + uh-data-loading 组件)
### 5.4 页面自定义导航(uh-navbar 组件)
**用途**:子页面(非 tabbar 页,如文章详情、设置、投票详情等)的顶部自定义导航栏。
配合页面 `navigationStyle: 'custom'` 使用,**新页面一律使用它,不要用默认导航栏**。
**前提(definePage 里必须声明)**
```ts
definePage({
style: {
navigationStyle: 'custom', // 关闭系统默认导航栏,让位给 uh-navbar
},
})
```
**Props 一览**
| Prop | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `use-back` | `boolean` | `true` | 是否显示左侧返回按钮 |
| `use-title` | `boolean` | `true` | 是否显示标题 |
| `default-title` | `string` | - | 默认标题(顶部文案) |
| `title-color` | `string` | - | 标题颜色类名(如 `text-gray-900`),**带此属性时固定颜色、不随滚动变色** |
| `scroll-title` | `string` | - | 滚动后标题(配合 default-title:顶部显示默认标题,滚过 50% 换 scroll-title |
| `need-placeholder` | `boolean` | `true` | 是否生成占位(为页面内容让出导航高度) |
**页面结构模板**(参考 `src/pages-blog/test/test.vue`):
```vue
<script lang="ts" setup>
definePage({
style: {
navigationBarTitleText: '测试页面',
navigationStyle: 'custom', // 使用 uh-navbar 必须声明
},
})
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
</script>
<template>
<!-- 最外层仅作容器(bg-page 底色),不设 padding -->
<view class="w-full min-h-screen bg-page">
<!-- 导航栏:置于页面最顶部,内置占位/安全区处理 -->
<uh-navbar default-title="测试页面" :need-placeholder="true" title-color="text-gray-900" />
<!-- 内容区:从这开始写,因为 uh-navbar 已内置占位(need-placeholder=true) -->
<view class="box-border px-3">
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus" min-height="80vh" />
<view v-else>
请求成功啦
</view>
</view>
</view>
</template>
```
**使用要点**
- **`need-placeholder` 必须按场景传对**
- 页面内容直接从导航下开始(普通子页面)→ `:need-placeholder="true"`(默认即可,如 test.vue / setting.vue
- 页面顶部有全屏封面/背景图,内容要盖到导航下面 → `:need-placeholder="false"`(如 article-detail.vue,封面 `pt-72` 上移)
- **`title-color` 与 `scroll-title` 二选一**
- 页面底色非深色 → 传 `title-color="text-gray-900"`(固定深色,如 test.vue / setting.vue
- 不传时标题会随滚动变白→深灰(顶部透明、滚后加深),适合顶部是深色大图的场景(如 article-detail.vue 只传 `default-title` + `scroll-title`,让滚动变色)
- 中间标题可用**默认插槽覆盖**(不传则显示 `default-title`/`scroll-title`),右侧扩展用 **`#right` 插槽**
```vue
<uh-navbar default-title="偏好设置" :need-placeholder="true">
<template #right>
<view @click="handleSave">保存</view>
</template>
</uh-navbar>
```
- 返回按钮内置(`uni.navigateBack`),无需自写
- easycom 已配置 `uh-` 前缀,直接用 `<uh-navbar />`**无需 import**
### 5.5 页面数据请求(统一 updateLoadingStatus + uh-data-loading 组件)
**数据加载四态**`loading / error / empty / success`,统一用
`useDataLoadingStatus``src/hooks/useDataLoadingStatus.ts`)的
@@ -419,7 +497,7 @@ onReachBottom(() => {
列表页/四态展示优先 `updateLoadingStatus` 写法
- 简单场景也可用 `useRequest(fn, { immediate })`:返回 `{ loading, error, data, run }`
### 5.5 列表页(分页加载)
### 5.6 列表页(分页加载)
列表页可用 z-pagingeasycom 已配置 `<z-paging>` 直接用)或手写分页。
手写分页的既有模式(参考 `src/pages/tabbar/home/home.vue`、`pages-blog/votes/votes.vue`):
@@ -457,7 +535,7 @@ onReachBottom(() => {
})
```
### 5.6 依赖插件的页面(插件可用性 + 维护拦截)
### 5.7 依赖插件的页面(插件可用性 + 维护拦截)
Halo 是插件化 CMS,页面可能依赖插件(投票 plugin-vote、瞬间 PluginMoments 等):
@@ -1,100 +1,79 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { getNotices } from '@/api/uni-halo'
import type { INoticeListVo } from '@/api/types/uni-halo'
import { computed, onMounted, ref } from 'vue'
import { getNotices } from '@/api/uni-halo'
import type { INoticeListVo } from '@/api/types/uni-halo'
/** 轮播展示条数上限(垂直循环) */
const MAX_SHOW = 6
/** 轮播展示条数上限(垂直循环) */
const MAX_SHOW = 6
const list = ref<INoticeListVo[]>([])
const showList = computed(() => list.value.slice(0, MAX_SHOW))
const list = ref<INoticeListVo[]>([])
const showList = computed(() => list.value.slice(0, MAX_SHOW))
async function fetchNotices() {
try {
const res = await getNotices({ page: 1, size: MAX_SHOW })
list.value = res.data?.items || []
}
catch (err) {
console.error('首页公告获取失败', err)
list.value = []
}
}
async function fetchNotices() {
try {
const res = await getNotices({ page: 1, size: MAX_SHOW })
list.value = res.data?.items || []
}
catch (err) {
console.error('首页公告获取失败', err)
list.value = []
}
}
/** 点击单条公告标题 → 详情页 */
function handleTap(item: INoticeListVo) {
if (!item.name)
return
uni.navigateTo({ url: `/pages-blog/notice/detail?name=${item.name}` })
}
/** 点击单条公告标题 → 详情页 */
function handleTap(item : INoticeListVo) {
if (!item.name) { return }
uni.navigateTo({ url: `/pages-blog/notice/detail?name=${item.name}` })
}
/** 点击「公告/更多」→ 公告列表页 */
function handleGoList() {
uni.navigateTo({ url: '/pages-blog/notice/notice' })
}
/** 点击「公告/更多」→ 公告列表页 */
function handleGoList() {
uni.navigateTo({ url: '/pages-blog/notice/notice' })
}
onMounted(() => {
fetchNotices()
})
onMounted(() => {
fetchNotices()
})
</script>
<template>
<view
v-if="showList.length > 0"
class="uh-global-card-glass box-border mx-3 mt-3 mb-2 flex items-center rounded-xl px-3"
>
<!-- 左侧公告入口 -->
<view class="flex shrink-0 items-center gap-1 py-2 pr-3" @click="handleGoList">
<text class="text-sm">
📢
</text>
<text class="text-xs font-bold text-red-400">
公告
</text>
</view>
<view v-if="showList.length > 0"
class="uh-global-card-glass box-border mx-3 mt-3 mb-2 flex items-center rounded-xl px-3">
<!-- 左侧公告入口 -->
<view class="flex shrink-0 items-center gap-2 py-2 pr-3" @click="handleGoList">
<wd-icon class-prefix="uhemoji2-icon" name="-happy-" size="36rpx" />
<text class="text-sm font-bold text-red-400">
公告
</text>
</view>
<!-- 标题轮播(仅一条时静态展示) -->
<view class="h-[60rpx] min-w-0 flex-1 overflow-hidden">
<swiper
v-if="showList.length > 1"
class="h-full w-full"
vertical
circular
autoplay
:interval="3500"
:duration="400"
>
<swiper-item
v-for="(item, index) in showList"
:key="item.name || index"
class="h-full w-full"
>
<view
class="flex h-full w-full items-center truncate text-xs text-gray-500"
@click="handleTap(item)"
>
{{ item.title }}
</view>
</swiper-item>
</swiper>
<view
v-else
class="flex h-full w-full items-center truncate text-[24rpx] text-[#555]"
@click="handleTap(showList[0])"
>
{{ showList[0]?.title }}
</view>
</view>
<!-- 标题轮播(仅一条时静态展示) -->
<view class="h-[60rpx] min-w-0 flex-1 overflow-hidden">
<swiper v-if="showList.length > 1" class="h-full w-full" vertical circular autoplay :interval="3500"
:duration="400">
<swiper-item v-for="(item, index) in showList" :key="item.name || index" class="h-full w-full">
<view class="flex h-full w-full items-center truncate text-xs text-gray-500"
@click="handleTap(item)">
{{ item.title }}
</view>
</swiper-item>
</swiper>
<view v-else class="flex h-full w-full items-center truncate text-[24rpx] text-[#555]"
@click="handleTap(showList[0])">
{{ showList[0]?.title }}
</view>
</view>
<!-- 右侧更多入口 -->
<view class="flex shrink-0 items-center gap-0.5 py-2 pl-2" @click="handleGoList">
<text class="text-[22rpx] text-[#bbb]">
更多
</text>
<wd-icon name="arrow-right" size="12px" color="#bbb" />
</view>
</view>
<!-- 右侧更多入口 -->
<view class="flex shrink-0 items-center gap-0.5 py-2 pl-2" @click="handleGoList">
<text class="text-[22rpx] text-[#bbb]">
更多
</text>
<wd-icon name="arrow-right" size="12px" color="#bbb" />
</view>
</view>
</template>
<style scoped lang="scss">
/* 布局由 UnoCSS 原子类实现 */
</style>
/* 布局由 UnoCSS 原子类实现 */
</style>
@@ -14,20 +14,27 @@
const navList = computed(() => {
const loveEnabled = !!(haloConfigs.value.loveConfig as { loveEnabled ?: boolean })?.loveEnabled
const socialEnabled = !!(haloConfigs.value.authorConfig?.social as { enabled ?: boolean } | undefined)?.enabled
// <wd-icon class-prefix="uhemoji-icon" name="-smile-" size="32rpx" />
return [
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
bgColor: 'rgba(3, 169, 244, 0.95)',
icon: 'news',
color: '#03A9F4',
bgGlass: 'rgba(3, 169, 244, 0.14)',
borderColor: 'rgba(3, 169, 244, 0.35)',
iconPrefix: 'uhemoji2-icon',
icon: '-mask',
path: '/pages-blog/archives/archives',
show: true,
},
{
key: 'vote',
title: '投票中心',
bgColor: 'rgba(0, 188, 212, 0.95)',
icon: 'box',
color: '#00BCD4',
bgGlass: 'rgba(0, 188, 212, 0.14)',
borderColor: 'rgba(0, 188, 212, 0.35)',
iconPrefix: 'uhemoji2-icon',
icon: '-confused',
path: '/pages-blog/votes/votes',
// show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
show: true,
@@ -35,8 +42,11 @@
{
key: 'disclaimers',
title: '友情链接',
bgColor: 'rgba(0, 150, 136, 0.95)',
icon: 'link',
color: '#009688',
bgGlass: 'rgba(0, 150, 136, 0.14)',
borderColor: 'rgba(0, 150, 136, 0.35)',
iconPrefix: 'uhemoji2-icon',
icon: '-wink',
path: '/pages-blog/friend-links/friend-links',
// show: calcLinksPluginEnabled.value,
show: true,
@@ -44,8 +54,11 @@
{
key: 'love',
title: '恋爱日记',
bgColor: 'rgba(255, 76, 103, 0.95)',
icon: 'heart',
color: '#FF4C67',
bgGlass: 'rgba(255, 76, 103, 0.14)',
borderColor: 'rgba(255, 76, 103, 0.075)',
iconPrefix: 'uhemoji2-icon',
icon: '-in-love',
path: '/pages-blog/love/love',
// show: loveEnabled,
show: true,
@@ -53,8 +66,11 @@
{
key: 'contact-blogger',
title: '联系博主',
bgColor: 'rgba(255, 152, 0, 0.95)',
icon: 'message',
color: '#FF9800',
bgGlass: 'rgba(255, 152, 0, 0.14)',
borderColor: 'rgba(255, 152, 0, 0.35)',
iconPrefix: 'uhemoji2-icon',
icon: '-cool',
path: '/pages-blog/contact/contact',
show: socialEnabled,
},
@@ -68,21 +84,37 @@
</script>
<template>
<view v-if="navList.length" class="overflow-hidden rounded-xl p-3 px-4 mb-3">
<view v-if="navList.length" class="box-border overflow-hidden rounded-xl p-3 px-4 mb-3">
<uh-section-title class="mb-4">
快捷导航
</uh-section-title>
<view class="grid grid-cols-5 gap-4">
<view v-for="item in navList" :key="item.key" class="flex flex-col items-center gap-2"
@click="handleClickNav(item)">
<view class="uh-global-card-glass border h-12 w-12 flex items-center justify-center rounded-2xl"
:style="{ backgroundColor: item.bgColor }">
<wd-icon :name="item.icon" size="24px" color="#fff" />
<view
class="uh-global-card-glass uh-shadow-xs h-13 w-13 flex items-center justify-center rounded-2xl border transition-transform active:scale-90"
:style="{
backgroundColor: item.bgGlass
}">
<wd-icon :class-prefix="item.iconPrefix" :name="item.icon" size="64rpx" :color="item.color" />
</view>
<view class="text-xs text-gray-900 font-bold">
<view class="text-xs text-gray-900">
{{ item.title }}
</view>
</view>
</view>
</view>
</template>
</template>
<style scoped lang="scss">
/* 彩色玻璃图标:低透明度同色底 + 同色描边 + 柔和同色投影,图标用实体色保证通透感 */
.quick-nav-icon {
backdrop-filter: blur(16rpx) saturate(160%);
-webkit-backdrop-filter: blur(16rpx) saturate(160%);
/* 低端安卓 WebView 不支持 backdrop-filter 的兜底:提高底色不透明度保证可读性 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
background-color: rgb(255 255 255 / 60%) !important;
}
}
</style>
+31 -19
View File
@@ -6,12 +6,15 @@
useBack : boolean;
useTitle : boolean;
defaultTitle ?: string;
titleColor ?: string;
scrollTitle ?: string;
needPlaceholder ?: boolean;
}
const props = withDefaults(defineProps<IProps>(), {
useBack: true,
useTitle: true,
needPlaceholder: true,
})
const slots = useSlots()
@@ -31,6 +34,10 @@
const customCalss = computed(() => {
const _class = []
if (props.titleColor) {
_class.push(props.titleColor)
return
}
if (scrollThreshold.value) {
_class.push('text-white')
}
@@ -49,9 +56,9 @@
}
return props.scrollTitle;
})
// todo:注意:如果是从分享进来的,我们需要处理为返回 home页面
function handleBack(){
function handleBack() {
uni.navigateBack({ delta: 1 })
}
@@ -61,25 +68,30 @@
</script>
<template>
<view class="box-border pt-safe w-full fixed left-0 top-0 z-50" :class="customCalss" :style="[customStyle]">
<view class="w-full h-[46px] flex items-center gap-x-4 box-border px-3 backdrop-blur-[2rpx]">
<!-- 左边 -->
<view class="shrink-0" @click="handleBack()">
<view
class="uh-global-card-glass h-7 px-3 rounded-full border flex items-center gap-x-2 text-gray-900 text-sm">
<wd-icon name="arrow-left" size="32rpx"></wd-icon>
<view class="w-[1px] h-4 bg-white/60" />
<text class="text-xs font-bold">返回</text>
<view class="w-full box-border">
<view class="box-border pt-safe w-full fixed left-0 top-0 z-50" :class="customCalss" :style="[customStyle]">
<view class="w-full h-[46px] flex items-center gap-x-4 box-border px-3 backdrop-blur-[2rpx]">
<!-- 左边 -->
<view class="shrink-0" @click="handleBack()">
<view
class="uh-global-card-glass h-7 px-3 rounded-full border flex items-center gap-x-2 text-gray-900 text-sm">
<wd-icon name="arrow-left" size="32rpx"></wd-icon>
<view class="w-[1px] h-4 bg-white/60" />
<text class="text-xs font-bold">返回</text>
</view>
</view>
<!-- 中间 -->
<view class="flex-1 truncate text-center font-bold transition-colors duration-300">
<slot> {{visibleTitle}} </slot>
</view>
<!-- 右边 -->
<view class="shrink-0 min-w-18">
<slot name="right"></slot>
</view>
</view>
<!-- 中间 -->
<view class="flex-1 truncate text-center font-bold transition-colors duration-300">
<slot> {{visibleTitle}} </slot>
</view>
<!-- 右边 -->
<view class="shrink-0 min-w-18">
<slot name="right"></slot>
</view>
</view>
<view v-if="props.needPlaceholder" class="box-border w-full pt-safe">
<view class="w-full h-[46px]"></view>
</view>
</view>
</template>
@@ -81,9 +81,7 @@
<!-- 头部:标题 + 关闭 -->
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<text class="text-sm">
📢
</text>
<wd-icon class-prefix="uhemoji2-icon" name="-happy-" size="36rpx" />
<text class="text-md font-bold text-gray-900">
最新公告
</text>
@@ -101,7 +99,8 @@
<!-- 内容 -->
<view class="mt-4">
<image v-if="notice.cover" :src="checkImageUrl(notice.cover)" class="w-full h-34 rounded-lg mb-2"></image>
<image v-if="notice.cover" :src="checkImageUrl(notice.cover)" class="w-full h-34 rounded-lg mb-2">
</image>
<view class="text-md font-bold leading-snug text-gray-900">
{{ notice.title }}
</view>
@@ -1,8 +1,4 @@
<script lang="ts" setup>
/**
* 插件不可用提示(源自旧项目 components/plugin-unavailable,新建复刻)
* 当依赖的 Halo 插件未安装/未启用时展示:插件 logo、名称、错误标签、描述、插件地址、复制/反馈按钮
*/
import { computed } from 'vue'
import { NeedPlugins } from '@/hooks/usePluginAvailable'
@@ -45,18 +41,7 @@
...defaultStyle,
...props.customStyle,
}))
function copy() {
if (!pluginInfo.value.url)
return
uni.setClipboardData({
data: pluginInfo.value.url,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: '插件地址已复制' })
},
})
}
</script>
<template>
@@ -13,7 +13,7 @@
}
// 获取当前页面,并且设置黑名单模式,因为有的页面可能不需要滚动到顶部
const balckList = ['pages/maintenance/maintenance']
const balckList = ['pages/maintenance/maintenance','pages-blog/setting/setting']
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const visible = computed(() => {
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -1,10 +1,4 @@
<script lang="ts" setup>
/**
* 收藏页(纯本地,文章 × 瞬间)
* 功能:双 Tab(仿图库顶部吸顶胶囊 chip)本地收藏列表 + 跳详情 + 删除 + 状态舞台
* 数据:useFavoritesStore(persist),快照自包含,无需网络加载
* 状态:useDataLoadingStatus + uh-data-loading(与 tabbar 页同构);本地同步数据,状态直接推导
*/
import { computed, ref, watchEffect } from 'vue'
import { formatTime } from '@/utils/formatTime'
import { useFavoritesStore } from '@/store/favorites'
@@ -14,7 +8,7 @@ import type { FavoriteKind, IFavoriteItem } from '@/utils/favorite'
definePage({
style: {
navigationBarTitleText: '我的收藏',
backgroundColor: '#f6f3ee',
navigationStyle: 'custom',
},
})
@@ -80,6 +74,9 @@ const emptyText = computed(() => (activeKind.value === 'post' ? '还没有收藏
<template>
<view class="box-border min-h-screen w-screen bg-page pb-10">
<!-- 自定义导航 -->
<uh-navbar default-title="我的收藏" title-color="text-gray-900"></uh-navbar>
<!-- 顶部类型 Tab(与图库页同款:吸顶玻璃胶囊 chip) -->
<wd-sticky>
<scroll-view scroll-x class="w-full whitespace-nowrap">
+283 -323
View File
@@ -1,349 +1,309 @@
<script lang="ts" setup>
/**
* 偏好设置页(两层:站点默认 L0 + 本地差异 L1-L,设计见 .docs/config-system-v2-redesign §3-4)
* - 每项可「跟随站点默认」(差异为空)或覆盖为具体值;改动即写本地差异(uh_pref_local_v1)即时生效;
* - 底部「重置为站点默认」= 清空全部本地差异,回退站长在插件后台配置的默认(未配置则为内置默认)。
* - 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title)
*/
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { DefaultAppSettings } from '@/config/appSettings'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { collectSiteDefaults, isLocalOverride, readLocalPrefs } from '@/utils/preference'
import type { LocalPrefs } from '@/utils/preference'
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { DefaultAppSettings } from '@/config/appSettings'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { collectSiteDefaults, isLocalOverride, readLocalPrefs } from '@/utils/preference'
import type { LocalPrefs } from '@/utils/preference'
definePage({
style: {
navigationBarTitleText: '偏好设置',
},
})
definePage({
style: {
navigationBarTitleText: '偏好设置',
navigationStyle: 'custom'
},
})
const settingStore = useSettingStore()
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const appConfigStore = useAppConfigStore()
/** 确保启动合并已执行(入口页未跑或 H5 直达时兜底) */
onLoad(() => {
if (!settingStore.siteDefaults) {
settingStore.applySiteDefaults(collectSiteDefaults(appConfigStore.configs))
}
uni.setNavigationBarTitle({ title: '偏好设置' })
})
/** 确保启动合并已执行(入口页未跑或 H5 直达时兜底) */
onLoad(() => {
if (!settingStore.siteDefaults) {
settingStore.applySiteDefaults(collectSiteDefaults(appConfigStore.configs))
}
uni.setNavigationBarTitle({ title: '偏好设置' })
})
/* ---------------- 路径取值工具 ---------------- */
type Path = string[]
/* ---------------- 路径取值工具 ---------------- */
type Path = string[]
function getByPath(obj: unknown, path: Path): unknown {
let cursor: unknown = obj
for (const key of path) {
if (cursor === null || cursor === undefined)
return undefined
cursor = (cursor as Record<string, unknown>)[key]
}
return cursor
}
function getByPath(obj : unknown, path : Path) : unknown {
let cursor : unknown = obj
for (const key of path) {
if (cursor === null || cursor === undefined)
return undefined
cursor = (cursor as Record<string, unknown>)[key]
}
return cursor
}
/** 按路径构造差异 patch(null 表示删除该键=跟随站点默认) */
function buildPatch(path: Path, value: unknown): LocalPrefs {
const [head, ...rest] = path
if (rest.length === 0)
return { [head]: value } as LocalPrefs
return { [head]: buildPatch(rest, value) } as LocalPrefs
}
/** 按路径构造差异 patch(null 表示删除该键=跟随站点默认) */
function buildPatch(path : Path, value : unknown) : LocalPrefs {
const [head, ...rest] = path
if (rest.length === 0)
return { [head]: value } as LocalPrefs
return { [head]: buildPatch(rest, value) } as LocalPrefs
}
/** 偏好字段定义(现页已有项;弹幕已下线、友链分组二期再开) */
interface PrefDef {
key: string
label: string
kind: 'bool' | 'enum'
path: Path
options?: { label: string, value: string }[]
siteLabelOf?: (value: string) => string
}
/** 偏好字段定义(现页已有项;弹幕已下线、友链分组二期再开) */
interface PrefDef {
key : string
label : string
kind : 'bool' | 'enum'
path : Path
options ?: { label : string, value : string }[]
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 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 featurePrefs: PrefDef[] = [
{ key: 'useWaterfull', label: '图库瀑布流模式', kind: 'bool', path: ['gallery', 'useWaterfull'] },
{ key: 'useSimple', label: '友链简洁模式', kind: 'bool', path: ['links', 'useSimple'] },
{ key: 'isAvatarRadius', label: '是否圆形头像', kind: 'bool', path: ['isAvatarRadius'] },
{ key: 'useDot', label: '轮播图指示器', kind: 'bool', path: ['banner', 'useDot'] },
{
key: 'dotPosition',
label: '指示器位置',
kind: 'enum',
path: ['banner', 'dotPosition'],
options: [
{ label: '右边', value: 'right' },
{ label: '下边', value: 'bottom' },
],
siteLabelOf: (value: string) => {
const map: Record<string, string> = { right: '右边', bottom: '下边', left: '左边', top: '上边' }
return map[value] || value
},
},
]
const featurePrefs : PrefDef[] = [
{ key: 'isAvatarRadius', label: '是否圆形头像', kind: 'bool', path: ['isAvatarRadius'] },
]
/* ---------------- 状态读取 ---------------- */
function valueOf(path: Path): unknown {
return getByPath(settingStore.settings, path)
}
/* ---------------- 状态读取 ---------------- */
function valueOf(path : Path) : unknown {
return getByPath(settingStore.settings, path)
}
/** 站点默认值(未配置时回退内置默认) */
function siteDefaultOf(path: Path): unknown {
const site = getByPath(settingStore.siteDefaults, path)
if (site !== undefined && site !== null)
return site
return getByPath(DefaultAppSettings, path)
}
/** 站点默认值(未配置时回退内置默认) */
function siteDefaultOf(path : Path) : unknown {
const site = getByPath(settingStore.siteDefaults, path)
if (site !== undefined && site !== null)
return site
return getByPath(DefaultAppSettings, path)
}
function isOverridden(path: Path): boolean {
return isLocalOverride(readLocalPrefs(), path)
}
function isOverridden(path : Path) : boolean {
return isLocalOverride(readLocalPrefs(), path)
}
function enumLabelOf(def: PrefDef, value: unknown): string {
const hit = def.options?.find(opt => opt.value === value)
if (hit)
return hit.label
if (def.siteLabelOf && typeof value === 'string')
return def.siteLabelOf(value)
return value === undefined || value === null ? '—' : String(value)
}
function enumLabelOf(def : PrefDef, value : unknown) : string {
const hit = def.options?.find(opt => opt.value === value)
if (hit)
return hit.label
if (def.siteLabelOf && typeof value === 'string')
return def.siteLabelOf(value)
return value === undefined || value === null ? '—' : String(value)
}
/* ---------------- 交互 ---------------- */
/** 开关事件(模板透传 $event) */
function handleSwitchChange(def: PrefDef, detail: { value?: unknown }) {
handleBoolChange(def.path, detail.value === true)
}
/* ---------------- 交互 ---------------- */
/** 开关事件(模板透传 $event) */
function handleSwitchChange(def : PrefDef, detail : { value ?: unknown }) {
handleBoolChange(def.path, detail.value === true)
}
/** 开关类:选值等于站点默认则还原为跟随(只存差异) */
function handleBoolChange(path: Path, next: boolean) {
if (next === siteDefaultOf(path)) {
settingStore.savePreference(buildPatch(path, null))
}
else {
settingStore.savePreference(buildPatch(path, next))
}
}
/** 开关类:选值等于站点默认则还原为跟随(只存差异) */
function handleBoolChange(path : Path, next : boolean) {
if (next === siteDefaultOf(path)) {
settingStore.savePreference(buildPatch(path, null))
}
else {
settingStore.savePreference(buildPatch(path, next))
}
}
/** 单项还原为跟随站点默认 */
function handleRevert(path: Path) {
settingStore.savePreference(buildPatch(path, null))
}
/** 单项还原为跟随站点默认 */
function handleRevert(path : Path) {
settingStore.savePreference(buildPatch(path, null))
}
/* ---------------- 枚举底部弹层 ---------------- */
const enumSheet = ref<{ show: boolean, def: PrefDef | null }>({ show: false, def: null })
/* ---------------- 枚举底部弹层 ---------------- */
const enumSheet = ref<{ show : boolean, def : PrefDef | null }>({ show: false, def: null })
function handleOpenEnum(def: PrefDef) {
enumSheet.value = { show: true, def }
}
function handleOpenEnum(def : PrefDef) {
enumSheet.value = { show: true, def }
}
function handleCloseEnum() {
enumSheet.value.show = false
}
function handleCloseEnum() {
enumSheet.value.show = false
}
function handleChooseEnum(value: string | null) {
const def = enumSheet.value.def
if (def) {
if (value === null || value === siteDefaultOf(def.path)) {
handleRevert(def.path)
}
else {
settingStore.savePreference(buildPatch(def.path, value))
}
}
handleCloseEnum()
}
function handleChooseEnum(value : string | null) {
const def = enumSheet.value.def
if (def) {
if (value === null || value === siteDefaultOf(def.path)) {
handleRevert(def.path)
}
else {
settingStore.savePreference(buildPatch(def.path, value))
}
}
handleCloseEnum()
}
/** 当前枚举项是否处于「跟随站点默认」 */
function isFollowing(def: PrefDef): boolean {
return !isOverridden(def.path)
}
/** 当前枚举项是否处于「跟随站点默认」 */
function isFollowing(def : PrefDef) : boolean {
return !isOverridden(def.path)
}
/* ---------------- 重置全部 ---------------- */
function handleResetAll() {
uni.showModal({
title: '提示',
content: '确定将所有偏好恢复为站点默认吗?本地自定义的偏好将被清除,未配置站点默认的项将恢复为内置默认。',
showCancel: true,
cancelText: '取消',
confirmText: '确定',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
settingStore.resetPreferences()
enumSheet.value.show = false
uni.showToast({ icon: 'none', title: '已恢复为站点默认' })
}
},
})
}
/* ---------------- 重置全部 ---------------- */
function handleResetAll() {
uni.showModal({
title: '提示',
content: '确定将所有偏好恢复为站点默认吗?本地自定义的偏好将被清除,未配置站点默认的项将恢复为内置默认。',
showCancel: true,
cancelText: '取消',
confirmText: '确定',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
settingStore.resetPreferences()
enumSheet.value.show = false
uni.showToast({ icon: 'none', title: '已恢复为站点默认' })
}
},
})
}
</script>
<template>
<view class="app-page box-border min-h-screen bg-page pb-[200rpx]">
<!-- 说明 -->
<view class="pref-tip uh-global-card-glass mx-4 mt-4 flex items-start gap-2 rounded-xl px-4 py-3">
<wd-icon name="info" size="28rpx" color="#a8a294" class="mt-0.5 shrink-0" />
<text class="tip-text flex-1 text-2xs text-gray-400 leading-[1.6]">
你的偏好仅保存在本机未自定义的项自动跟随站长在插件后台配置的站点默认点击底部重置为站点默认可清空全部本地偏好
</text>
</view>
<!-- 布局设置 -->
<uh-section-title class="mx-4 mb-3 mt-6 text-[30rpx]">
布局
<template #right>
<text class="text-2xs text-gray-400">应用以及文章列表布局设置</text>
</template>
</uh-section-title>
<view class="setting-sheet uh-global-card-glass mx-4 overflow-hidden rounded-2xl">
<view
v-for="(def, index) in layoutPrefs"
:key="def.key"
class="pick-row flex items-center justify-between px-4 py-4"
:class="index < layoutPrefs.length - 1 ? 'border-b border-black/5' : ''"
@click="handleOpenEnum(def)"
>
<view class="row-left flex flex-col gap-1">
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
<view class="flex items-center gap-2">
<text v-if="isFollowing(def)" 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 class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-gray-400">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
</view>
</view>
</view>
<!-- 功能设置 -->
<uh-section-title class="mx-4 mb-3 mt-6 text-[30rpx]">
功能
<template #right>
<text class="text-2xs text-gray-400">一些常用的功能性设置</text>
</template>
</uh-section-title>
<view class="setting-sheet uh-global-card-glass mx-4 overflow-hidden rounded-2xl">
<template v-for="(def, index) in featurePrefs" :key="def.key">
<!-- 布尔开关 -->
<view
v-if="def.kind === 'bool'"
class="switch-row flex items-center justify-between px-4 py-4"
:class="index < featurePrefs.length - 1 ? 'border-b border-black/5' : ''"
>
<view class="row-left flex flex-col gap-1">
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
<view class="flex items-center gap-2">
<text v-if="isFollowing(def)" class="row-sub text-2xs text-gray-400">跟随站点默认</text>
<template v-else>
<view class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
已自定义
</view>
<text class="revert-text text-2xs text-gray-400 underline" @click.stop="handleRevert(def.path)">恢复默认</text>
</template>
</view>
</view>
<wd-switch :model-value="!!valueOf(def.path)" @change="handleSwitchChange(def, $event)" />
</view>
<!-- 枚举选择(指示器位置) -->
<view
v-else
class="pick-row flex items-center justify-between px-4 py-4"
:class="index < featurePrefs.length - 1 ? 'border-b border-black/5' : ''"
@click="handleOpenEnum(def)"
>
<view class="row-left flex flex-col gap-1">
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
<view class="flex items-center gap-2">
<text v-if="isFollowing(def)" class="row-sub text-2xs text-gray-400">跟随站点默认</text>
<template v-else>
<view class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
已自定义
</view>
<text class="revert-text text-2xs text-gray-400 underline" @click.stop="handleRevert(def.path)">恢复默认</text>
</template>
</view>
</view>
<view class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-gray-400">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
</view>
</view>
</template>
</view>
<!-- 底部操作栏(玻璃悬浮 + 底部安全区适配) -->
<view class="btn-bar uh-global-card-glass fixed bottom-0 left-0 box-border w-screen px-4 pt-3 pb-safe">
<view class="reset-btn h-[84rpx] w-full flex items-center justify-center rounded-full bg-gray-900 active:opacity-80" @click="handleResetAll">
<text class="text-[28rpx] text-white font-bold">重置为站点默认</text>
</view>
</view>
<!-- 枚举选择底部弹层 -->
<wd-popup
v-model="enumSheet.show"
position="bottom"
closable
custom-style="border-radius: 24rpx 24rpx 0 0;"
@close="handleCloseEnum"
>
<view v-if="enumSheet.def" class="enum-sheet box-border w-full pb-[env(safe-area-inset-bottom)]">
<view class="enum-title py-6 text-center text-[30rpx] text-gray-900 font-bold">
{{ enumSheet.def.label }}
</view>
<view
class="enum-item flex items-center justify-between px-6 py-5"
:class="isFollowing(enumSheet.def) ? 'bg-secondary text-[#4d7c0f]' : 'text-gray-900'"
@click="handleChooseEnum(null)"
>
<text class="text-[28rpx]">跟随站点默认</text>
<wd-icon v-if="isFollowing(enumSheet.def)" name="check" size="16px" color="#4d7c0f" />
</view>
<view
v-for="opt in enumSheet.def.options"
:key="opt.value"
class="enum-item flex items-center justify-between border-t border-[#f0ece2] px-6 py-5"
:class="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def) ? 'bg-secondary text-[#4d7c0f]' : 'text-gray-900'"
@click="handleChooseEnum(opt.value)"
>
<text class="text-[28rpx]">{{ opt.label }}</text>
<wd-icon
v-if="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def)"
name="check"
size="16px"
color="#4d7c0f"
/>
</view>
</view>
</wd-popup>
</view>
</template>
<view class="box-border min-h-screen bg-page">
<!-- 自定义标题 -->
<uh-navbar default-title="偏好设置" title-color="text-gray-900" :need-placeholder="true"></uh-navbar>
<!-- 内容区域 -->
<view class="box-border p-3 flex flex-col gap-y-6">
<!-- 布局设置 -->
<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="(def, index) in layoutPrefs" :key="def.key"
class="pick-row flex items-center justify-between px-4 py-4"
:class="index < layoutPrefs.length - 1 ? 'border-b border-black/5' : ''"
@click="handleOpenEnum(def)">
<view class="row-left flex flex-col gap-1">
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
<view class="flex items-center gap-2">
<text v-if="isFollowing(def)" 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 class="row-value flex items-center gap-2">
<text
class="value-text text-[26rpx] text-gray-400">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
</view>
</view>
</view>
</view>
<!-- 功能设置 -->
<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="(def, index) in featurePrefs" :key="def.key">
<!-- 布尔开关 -->
<view v-if="def.kind === 'bool'" class="switch-row flex items-center justify-between px-4 py-4"
:class="index < featurePrefs.length - 1 ? 'border-b border-black/5' : ''">
<view class="row-left flex flex-col gap-1">
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
<view class="flex items-center gap-2">
<text v-if="isFollowing(def)" class="row-sub text-2xs text-gray-400">跟随站点默认</text>
<template v-else>
<view
class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
已自定义
</view>
<text class="revert-text text-2xs text-gray-400 underline"
@click.stop="handleRevert(def.path)">恢复默认</text>
</template>
</view>
</view>
<wd-switch :model-value="def.path" @change="handleSwitchChange(def, $event)" />
</view>
<!-- 枚举选择(指示器位置) -->
<view v-else class="pick-row flex items-center justify-between px-4 py-4"
:class="index < featurePrefs.length - 1 ? 'border-b border-black/5' : ''"
@click="handleOpenEnum(def)">
<view class="row-left flex flex-col gap-1">
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
<view class="flex items-center gap-2">
<text v-if="isFollowing(def)" class="row-sub text-2xs text-gray-400">跟随站点默认</text>
<template v-else>
<view
class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
已自定义
</view>
<text class="revert-text text-2xs text-gray-400 underline"
@click.stop="handleRevert(def.path)">恢复默认</text>
</template>
</view>
</view>
<view class="row-value flex items-center gap-2">
<text
class="value-text text-[26rpx] text-gray-400">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
</view>
</view>
</template>
</view>
</view>
<!-- 底部操作栏(玻璃悬浮-->
<view class="box-border w-full px-2">
<uh-button custom-class="uh-global-card-glass py-2 !rounded-full"
@click="handleResetAll">恢复默认</uh-button>
</view>
</view>
<!-- 枚举选择底部弹层 -->
<wd-popup v-model="enumSheet.show" position="bottom" closable custom-style="border-radius: 24rpx 24rpx 0 0;"
@close="handleCloseEnum">
<view v-if="enumSheet.def" class="enum-sheet box-border w-full pb-[env(safe-area-inset-bottom)]">
<view class="enum-title py-6 text-center text-[30rpx] text-gray-900 font-bold">
{{ enumSheet.def.label }}
</view>
<view class="enum-item flex items-center justify-between px-6 py-5"
:class="isFollowing(enumSheet.def) ? 'bg-secondary text-[#4d7c0f]' : 'text-gray-900'"
@click="handleChooseEnum(null)">
<text class="text-[28rpx]">跟随站点默认</text>
<wd-icon v-if="isFollowing(enumSheet.def)" name="check" size="16px" color="#4d7c0f" />
</view>
<view v-for="opt in enumSheet.def.options" :key="opt.value"
class="enum-item flex items-center justify-between border-t border-[#f0ece2] px-6 py-5"
:class="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def) ? 'bg-secondary text-[#4d7c0f]' : 'text-gray-900'"
@click="handleChooseEnum(opt.value)">
<text class="text-[28rpx]">{{ opt.label }}</text>
<wd-icon v-if="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def)"
name="check" size="16px" color="#4d7c0f" />
</view>
</view>
</wd-popup>
</view>
</template>
+22 -3
View File
@@ -1,14 +1,33 @@
<script setup lang="ts">
import { DataLoadingStatusEnum } from '@/hooks/useDataLoadingStatus'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
definePage({
style: {
navigationBarTitleText: '测试页面',
navigationStyle: 'custom',
},
})
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
setTimeout(() => {
updateLoadingStatus(DataLoadingStatusEnum.Success)
}, 3000)
</script>
<template>
<view class="w-full h-screen">
<uh-data-loading :loading-status="DataLoadingStatusEnum.Loading"></uh-data-loading>
<view class="w-full min-h-screen bg-page">
<uh-navbar default-title="测试页面" :need-placeholder="true" title-color="text-gray-900"></uh-navbar>
<!-- 内容区由于 uh-navbar 内置有占位所以我们的页面的主要内容应该从这里开始比如这里就可以设置内边距或者其他样式最外层的 <view class="w-full min-h-screen bg-page"> 仅作为容器-->
<view class="box-border px-3">
<!-- 加载状态 -->
<uh-data-loading v-if="loadingStatus!==DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
min-height="80vh"></uh-data-loading>
<!-- 实际内容 -->
<view v-else>
请求成功啦
</view>
</view>
</view>
</template>
+343 -337
View File
@@ -1,375 +1,381 @@
<script lang="ts" setup>
/**
/**
* 关于页(源自旧项目 pages/tabbar/about/about.vue,新建复刻)
* 功能:博主信息 + 站点统计 + 功能导航 + 版权
* 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title + 彩色图标块)
*/
import { computed, ref, watch } from 'vue'
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { getBlogStatistics } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useFavoritesStore } from '@/store/favorites'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { checkHasAdminLogin } from '@/utils/auth'
import { t } from '@/locale'
import type { IBlogStats } from '@/api/types/halo'
import { computed, ref, watch } from 'vue'
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { getBlogStatistics } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useFavoritesStore } from '@/store/favorites'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { checkHasAdminLogin } from '@/utils/auth'
import { t } from '@/locale'
import type { IBlogStats } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '关于',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
definePage({
style: {
navigationBarTitleText: '关于',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const favoritesStore = useFavoritesStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
/** 数据看板插件可用性(供导航项显隐判断) */
const { check: checkDataVisualPlugin } = usePluginAvailable('plugin-data-statistics')
const appConfigStore = useAppConfigStore()
const favoritesStore = useFavoritesStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
/** 数据看板插件可用性(供导航项显隐判断) */
const { check: checkDataVisualPlugin } = usePluginAvailable('plugin-data-statistics')
/* ---------------- 计算属性 ---------------- */
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as
| { nickname?: string, avatar?: string, description?: string }
| undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
description: blogger?.description || '',
}
})
/* ---------------- 计算属性 ---------------- */
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as
| { nickname ?: string, avatar ?: string, description ?: string }
| undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
description: blogger?.description || '',
}
})
const pageConfig = computed(() => haloConfigs.value.pageConfig?.aboutConfig as
| { bgImageUrl?: string, waveImageUrl?: string }
| undefined)
const pageConfig = computed(() => haloConfigs.value.pageConfig?.aboutConfig as
| { bgImageUrl ?: string, waveImageUrl ?: string }
| undefined)
const calcProfileStyle = computed(() => ({
backgroundImage: `url(${checkImageUrl(pageConfig.value?.bgImageUrl)})`,
}))
const calcProfileStyle = computed(() => ({
backgroundImage: `url(${checkImageUrl(pageConfig.value?.bgImageUrl)})`,
}))
const calcWaveUrl = computed(() => checkImageUrl(pageConfig.value?.waveImageUrl))
const calcWaveUrl = computed(() => checkImageUrl(pageConfig.value?.waveImageUrl))
const basicConfig = computed(() => haloConfigs.value.basicConfig as
| {
copyrightConfig?: { enabled?: boolean, content?: string }
disclaimers?: { enabled?: boolean }
showAboutSystem?: boolean
}
| undefined)
const basicConfig = computed(() => haloConfigs.value.basicConfig as
| {
copyrightConfig ?: { enabled ?: boolean, content ?: string }
disclaimers ?: { enabled ?: boolean }
showAboutSystem ?: boolean
}
| undefined)
const copyrightConfig = computed(() => basicConfig.value?.copyrightConfig)
const copyrightConfig = computed(() => basicConfig.value?.copyrightConfig)
const loveEnabled = computed(() => !!(haloConfigs.value.loveConfig as { loveEnabled?: boolean } | undefined)?.loveEnabled)
const socialEnabled = computed(() => !!(haloConfigs.value.authorConfig?.social as { enabled?: boolean } | undefined)?.enabled)
const loveEnabled = computed(() => !!(haloConfigs.value.loveConfig as { loveEnabled ?: boolean } | undefined)?.loveEnabled)
const socialEnabled = computed(() => !!(haloConfigs.value.authorConfig?.social as { enabled ?: boolean } | undefined)?.enabled)
/* ---------------- 状态 ---------------- */
const statisticsShowMore = ref(false)
const statistics = ref<IBlogStats>({ post: 0, comment: 0, category: 0, visit: 0, upvote: 0 })
/* ---------------- 状态 ---------------- */
const statisticsShowMore = ref(false)
const statistics = ref<IBlogStats>({ post: 0, comment: 0, category: 0, visit: 0, upvote: 0 })
/** 主行统计(常驻展示) */
const allStats = computed(() => [
{ key: 'post', label: '内容', value: statistics.value.post },
{ key: 'visit', label: '访客', value: statistics.value.visit },
{ key: 'category', label: '分类', value: statistics.value.category },
{ key: 'comment', label: '评论', value: statistics.value.comment },
{ key: 'upvote', label: '点赞', value: statistics.value.upvote },
])
/** 主行统计(常驻展示) */
const allStats = computed(() => [
{ key: 'post', label: '内容', value: statistics.value.post },
{ key: 'visit', label: '访客', value: statistics.value.visit },
{ key: 'category', label: '分类', value: statistics.value.category },
{ key: 'comment', label: '评论', value: statistics.value.comment },
{ key: 'upvote', label: '点赞', value: statistics.value.upvote },
])
interface INavItem {
key: string
title: string
icon: string
/** 图标块背景色(与首页快捷导航同色板,同一功能同色) */
bgColor: string
rightText: string
path: string | null
isAdmin?: boolean
openType?: string
show: boolean
/** 分组:blog=博客功能 more=更多信息 */
group: 'blog' | 'more'
}
interface INavItem {
key : string
title : string
iconPrefix ?: string
icon : string
/** 图标块背景色(与首页快捷导航同色板,同一功能同色) */
bgColor : string
rightText : string
path : string | null
isAdmin ?: boolean
openType ?: string
show : boolean
/** 分组:blog=博客功能 more=更多信息 */
group : 'blog' | 'more'
}
const navList = ref<INavItem[]>([])
const navList = ref<INavItem[]>([])
/** 分组渲染(过滤后空组整组隐藏) */
const calcNavGroups = computed(() => {
const visible = navList.value.filter(n => n.show)
const groupDefs: { key: 'blog' | 'more', title: string }[] = [
{ key: 'blog', title: '博客功能' },
{ key: 'more', title: '其他功能' },
]
return groupDefs
.map(def => ({ ...def, items: visible.filter(n => n.group === def.key) }))
.filter(group => group.items.length > 0)
})
/** 分组渲染(过滤后空组整组隐藏) */
const calcNavGroups = computed(() => {
const visible = navList.value.filter(n => n.show)
const groupDefs : { key : 'blog' | 'more', title : string }[] = [
{ key: 'blog', title: '博客功能' },
{ key: 'more', title: '其他功能' },
]
return groupDefs
.map(def => ({ ...def, items: visible.filter(n => n.group === def.key) }))
.filter(group => group.items.length > 0)
})
/* ---------------- 功能导航 ---------------- */
/** 图标块浅色背景:品牌深色 rgba 降透明度 → 轻量底色 */
function toLightBg(rgba: string) {
return rgba.replace('0.95)', '0.15)')
}
/* ---------------- 功能导航 ---------------- */
/** 图标块浅色背景:品牌深色 rgba 降透明度 → 轻量底色 */
function toLightBg(rgba : string) {
return rgba.replace('0.95)', '0.15)')
}
/** 图标颜色:品牌深色实色 */
function toSolidColor(rgba: string) {
return rgba.replace('0.95)', '1)')
}
/** 图标颜色:品牌深色实色 */
function toSolidColor(rgba : string) {
return rgba.replace('0.95)', '1)')
}
/** 收藏导航项右侧文案跟随收藏总数(收藏页返回/切回时刷新) */
function syncFavoritesNavText() {
const nav = navList.value.find(n => n.key === 'favorites')
if (nav) {
nav.rightText = `${favoritesStore.counts.total} 条收藏`
}
}
/** 收藏导航项右侧文案跟随收藏总数(收藏页返回/切回时刷新) */
function syncFavoritesNavText() {
const nav = navList.value.find(n => n.key === 'favorites')
if (nav) {
nav.rightText = `${favoritesStore.counts.total} 条收藏`
}
}
async function handleGetNavList() {
const dataVisualAvailable = await checkDataVisualPlugin()
async function handleGetNavList() {
const dataVisualAvailable = await checkDataVisualPlugin()
navList.value = [
{
key: 'favorites',
title: '我的收藏',
icon: 'star',
bgColor: 'rgba(255, 179, 0, 0.95)',
rightText: '',
path: '/pages-blog/favorites/favorites',
show: true,
group: 'blog',
},
{
key: 'data-visual',
title: '数据看板',
icon: 'chart',
bgColor: 'rgba(102, 60, 201, 0.95)',
rightText: '站点数据可视化',
path: '/pages-blog/data-visual/data-visual',
show: dataVisualAvailable,
group: 'blog',
},
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
icon: 'folder',
bgColor: 'rgba(3, 169, 244, 0.95)',
rightText: calcAuditModeEnabled.value ? '全部已归档内容' : '全部已归档文章',
path: '/pages-blog/archives/archives',
show: true,
group: 'blog',
},
{
key: 'love',
title: '恋爱日记',
icon: 'heart',
bgColor: 'rgba(255, 76, 103, 0.95)',
rightText: '博主的恋爱日记',
path: '/pages-blog/love/love',
show: loveEnabled.value,
// show: true,
group: 'blog',
},
{
key: 'vote',
title: '投票中心',
icon: 'box',
bgColor: 'rgba(0, 188, 212, 0.95)',
rightText: '查看和进行投票',
path: '/pages-blog/votes/votes',
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
// show: true,
group: 'blog',
},
{
key: 'friend-links',
title: '友情链接',
icon: 'link',
bgColor: 'rgba(0, 150, 136, 0.95)',
rightText: '看看博主朋友们吧',
path: '/pages-blog/friend-links/friend-links',
show: calcLinksPluginEnabled.value,
// show: true,
group: 'blog',
},
{
key: 'disclaimers',
title: '免责声明',
icon: 'map',
bgColor: 'rgba(121, 85, 72, 0.95)',
rightText: '博客内容免责声明',
path: '/pages-blog/disclaimers/disclaimers',
show: !!basicConfig.value?.disclaimers?.enabled,
// show: true,
group: 'more',
},
{
key: 'contact-blogger',
title: '联系博主',
icon: 'message',
bgColor: 'rgba(255, 152, 0, 0.95)',
rightText: '博主常用联系方式',
path: '/pages-blog/contact/contact',
show: socialEnabled.value,
// show: true,
group: 'more',
},
{
key: 'about',
title: '关于项目',
icon: 'info',
bgColor: 'rgba(96, 125, 139, 0.95)',
rightText: '小莫唐尼开源项目',
path: '/pages-blog/about/about',
show: !!basicConfig.value?.showAboutSystem,
// show: true,
group: 'more',
},
{
key: 'setting',
title: '偏好设置',
icon: 'settings',
bgColor: 'rgba(121, 134, 203, 0.95)',
rightText: '首页布局、卡片样式等本地偏好',
path: '/pages-blog/setting/setting',
show: true,
group: 'more',
},
]
syncFavoritesNavText()
}
navList.value = [
{
key: 'favorites',
title: '我的收藏',
iconPrefix: 'uhemoji2-icon',
icon: '-smiling',
bgColor: 'rgba(255, 179, 0, 0.95)',
rightText: '',
path: '/pages-blog/favorites/favorites',
show: true,
group: 'blog',
},
{
key: 'data-visual',
title: '数据看板',
iconPrefix: 'uhemoji2-icon',
icon: '-surprised',
bgColor: 'rgba(102, 60, 201, 0.95)',
rightText: '站点数据可视化',
path: '/pages-blog/data-visual/data-visual',
show: dataVisualAvailable,
group: 'blog',
},
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
iconPrefix: 'uhemoji2-icon',
icon: '-mask',
bgColor: 'rgba(3, 169, 244, 0.95)',
rightText: calcAuditModeEnabled.value ? '全部已归档内容' : '全部已归档文章',
path: '/pages-blog/archives/archives',
show: true,
group: 'blog',
},
{
key: 'love',
title: '恋爱日记',
iconPrefix: 'uhemoji2-icon',
icon: '-in-love',
bgColor: 'rgba(255, 76, 103, 0.95)',
rightText: '博主的恋爱日记',
path: '/pages-blog/love/love',
show: loveEnabled.value,
// show: true,
group: 'blog',
},
{
key: 'vote',
title: '投票中心',
iconPrefix: 'uhemoji2-icon',
icon: '-confused',
bgColor: 'rgba(0, 188, 212, 0.95)',
rightText: '查看和进行投票',
path: '/pages-blog/votes/votes',
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
// show: true,
group: 'blog',
},
{
key: 'friend-links',
title: '友情链接',
iconPrefix: 'uhemoji2-icon',
icon: '-cool',
bgColor: 'rgba(0, 150, 136, 0.95)',
rightText: '看看博主朋友们吧',
path: '/pages-blog/friend-links/friend-links',
show: calcLinksPluginEnabled.value,
// show: true,
group: 'blog',
},
{
key: 'disclaimers',
title: '免责声明',
iconPrefix: 'uhemoji2-icon',
icon: '-smirking',
bgColor: 'rgba(121, 85, 72, 0.95)',
rightText: '博客内容免责声明',
path: '/pages-blog/disclaimers/disclaimers',
show: !!basicConfig.value?.disclaimers?.enabled,
// show: true,
group: 'more',
},
{
key: 'contact-blogger',
title: '联系博主',
iconPrefix: 'uhemoji2-icon',
icon: '-wink',
bgColor: 'rgba(255, 152, 0, 0.95)',
rightText: '博主常用联系方式',
path: '/pages-blog/contact/contact',
show: socialEnabled.value,
// show: true,
group: 'more',
},
{
key: 'about',
title: '关于项目',
iconPrefix: 'uhemoji2-icon',
icon: '-happy-',
bgColor: 'rgba(96, 125, 139, 0.95)',
rightText: '小莫唐尼开源项目',
path: '/pages-blog/about/about',
show: !!basicConfig.value?.showAboutSystem,
// show: true,
group: 'more',
},
{
key: 'setting',
title: '偏好设置',
iconPrefix: 'uhemoji2-icon',
icon: '-tired',
bgColor: 'rgba(121, 134, 203, 0.95)',
rightText: '首页布局、卡片样式等本地偏好',
path: '/pages-blog/setting/setting',
show: true,
group: 'more',
},
]
syncFavoritesNavText()
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
try {
const res = await getBlogStatistics()
statistics.value = res.data
}
catch (err) {
console.error('获取统计失败', err)
uni.showToast({ icon: 'none', title: t('common.loadFailedRetry') })
}
finally {
uni.stopPullDownRefresh()
}
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
try {
const res = await getBlogStatistics()
statistics.value = res.data
}
catch (err) {
console.error('获取统计失败', err)
uni.showToast({ icon: 'none', title: t('common.loadFailedRetry') })
}
finally {
uni.stopPullDownRefresh()
}
}
/* ---------------- 交互 ---------------- */
function handleOnNav(data: { path: string | null, isAdmin?: boolean }) {
const { path, isAdmin } = data
if (!path)
return
/* ---------------- 交互 ---------------- */
function handleOnNav(data : { path : string | null, isAdmin ?: boolean }) {
const { path, isAdmin } = data
if (!path)
return
// 拦截后台管理页面(需超管登录)
if (isAdmin && !checkHasAdminLogin()) {
uni.showModal({
title: '提示',
content: '未登录超管账号或登录状态已过期,是否立即登录?',
showCancel: true,
cancelText: '否',
cancelColor: '#999999',
confirmText: '是',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/auth/login' })
}
},
})
return
}
// 拦截后台管理页面(需超管登录)
if (isAdmin && !checkHasAdminLogin()) {
uni.showModal({
title: '提示',
content: '未登录超管账号或登录状态已过期,是否立即登录?',
showCancel: true,
cancelText: '否',
cancelColor: '#999999',
confirmText: '是',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/auth/login' })
}
},
})
return
}
uni.navigateTo({ url: path })
}
uni.navigateTo({ url: path })
}
/* ---------------- 生命周期 ---------------- */
watch(haloConfigs, () => {
handleGetNavList()
}, { deep: true, immediate: true })
/* ---------------- 生命周期 ---------------- */
watch(haloConfigs, () => {
handleGetNavList()
}, { deep: true, immediate: true })
handleGetData()
handleGetData()
// 从收藏页返回/切回时刷新收藏数文案
onShow(() => {
syncFavoritesNavText()
})
// 从收藏页返回/切回时刷新收藏数文案
onShow(() => {
syncFavoritesNavText()
})
onPullDownRefresh(() => {
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
</script>
<template>
<view class="box-border min-h-screen w-screen bg-page pb-8">
<!-- 头部:博主信息(背景图 + 遮罩 + wave,内容区做状态栏适配) -->
<view class="blogger-info relative h-76 w-full bg-cover bg-no-repeat" :style="[calcProfileStyle]">
<!-- 背景遮罩 -->
<view class="absolute left-0 top-0 z-0 h-full w-full bg-black/30 backdrop-blur-[2rpx]" />
<view class="relative z-6 h-full flex flex-col items-center justify-center pb-[140rpx] pt-safe">
<image
class="uh-global-card-glass h-20 w-20 rounded-full" :src="bloggerInfo.avatar"
mode="aspectFill"
/>
<view class="mt-4 text-lg text-white font-bold text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
{{ bloggerInfo.nickname }}
</view>
<view
class="desc mt-2 px-10 text-center text-[26rpx] text-white/90 leading-relaxed text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]"
>
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
</view>
</view>
<image
v-if="calcWaveUrl" :src="calcWaveUrl" mode="scaleToFill"
class="gif-wave absolute bottom-0 left-0 z-99 h-[100rpx] w-full" style="mix-blend-mode: screen;"
/>
</view>
<view class="box-border min-h-screen w-screen bg-page pb-8">
<!-- 头部:博主信息(背景图 + 遮罩 + wave,内容区做状态栏适配) -->
<view class="blogger-info relative h-76 w-full bg-cover bg-no-repeat" :style="[calcProfileStyle]">
<!-- 背景遮罩 -->
<view class="absolute left-0 top-0 z-0 h-full w-full bg-black/30 backdrop-blur-[2rpx]" />
<view class="relative z-6 h-full flex flex-col items-center justify-center pb-[140rpx] pt-safe">
<image class="uh-global-card-glass h-20 w-20 rounded-full" :src="bloggerInfo.avatar"
mode="aspectFill" />
<view class="mt-4 text-lg text-white font-bold text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
{{ bloggerInfo.nickname }}
</view>
<view
class="desc mt-2 px-10 text-center text-[26rpx] text-white/90 leading-relaxed text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
</view>
</view>
<image v-if="calcWaveUrl" :src="calcWaveUrl" mode="scaleToFill"
class="gif-wave absolute bottom-0 left-0 z-99 h-[100rpx] w-full" style="mix-blend-mode: screen;" />
</view>
<!-- 站点统计(上浮玻璃卡,与头部衔接) -->
<view class="uh-global-card-glass relative z-100 mx-4 flex border rounded-2xl -mt-12">
<view v-for="item in allStats" :key="item.key" class="flex-1 py-6 text-center">
<view class="text-lg text-gray-900 font-bold">
{{ item.value }}
</view>
<view class="mt-1 text-xs text-gray-500">
{{ item.label }}
</view>
</view>
</view>
<!-- 站点统计(上浮玻璃卡,与头部衔接) -->
<view class="uh-global-card-glass relative z-100 mx-4 flex border rounded-2xl -mt-12">
<view v-for="item in allStats" :key="item.key" class="flex-1 py-6 text-center">
<wd-count-to
:key="`${item.key}-${item.value}`" :start-val="0" :end-val="item.value"
:duration="900" separator="" color="#111827" custom-class="text-lg font-bold"
/>
<view class="mt-1 text-xs text-gray-500">
{{ item.label }}
</view>
</view>
</view>
<!-- 功能导航(分组玻璃卡) -->
<template v-for="group in calcNavGroups" :key="group.key">
<uh-section-title class="mx-4 mb-3 mt-8">
{{ group.title }}
</uh-section-title>
<view class="nav-wrap uh-global-card-glass mx-4 overflow-hidden rounded-2xl">
<view
v-for="(nav, index) in group.items" :key="nav.key"
class="nav-item flex items-center justify-between px-4"
:class="index < group.items.length - 1 ? 'border-b border-b-solid border-black/5' : ''" @click="handleOnNav(nav)"
>
<view class="nav-left flex items-center gap-3 py-3">
<view
class="h-9 w-9 flex items-center justify-center border border-black/5 rounded-xl"
:style="{ backgroundColor: toLightBg(nav.bgColor) }"
>
<wd-icon :name="nav.icon" size="20px" :color="toSolidColor(nav.bgColor)" />
</view>
<text class="nav-title text-sm text-gray-900 font-bold">{{ nav.title }}</text>
</view>
<view class="nav-right flex items-center gap-2">
<text class="nav-right-text text-xs text-gray-400">{{ nav.rightText }}</text>
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
</view>
</view>
</view>
</template>
<!-- 功能导航(分组玻璃卡) -->
<template v-for="group in calcNavGroups" :key="group.key">
<uh-section-title class="mx-4 mb-3 mt-8">
{{ group.title }}
</uh-section-title>
<view class="uh-global-card-glass mx-4 overflow-hidden rounded-2xl">
<view v-for="(nav, index) in group.items" :key="nav.key"
class="nav-item flex items-center justify-between px-4"
:class="index < group.items.length - 1 ? 'border-b border-b-solid border-black/5' : ''"
@click="handleOnNav(nav)">
<view class="nav-left flex items-center gap-3 py-3">
<view
class="uh-global-card-glass border uh-shadow-xs h-8 w-8 flex items-center justify-center rounded-xl"
:style="{ backgroundColor: toLightBg(nav.bgColor) }">
<wd-icon :class-prefix="nav.iconPrefix" :name="nav.icon" size="36rpx"
:color="toSolidColor(nav.bgColor)" />
</view>
<text class="nav-title text-sm text-gray-900 font-bold">{{ nav.title }}</text>
</view>
<view class="nav-right flex items-center gap-2">
<text class="nav-right-text text-xs text-gray-400">{{ nav.rightText }}</text>
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
</view>
</view>
</view>
</template>
<!-- 版权 -->
<view v-if="copyrightConfig?.enabled" class="mt-6 px-6 text-center text-xs text-gray-400">
<view>{{ copyrightConfig.content }}</view>
</view>
</view>
</template>
<!-- 版权 -->
<view v-if="copyrightConfig?.enabled" class="mt-6 px-6 text-center text-xs text-gray-400">
<view>{{ copyrightConfig.content }}</view>
</view>
</view>
</template>
-2
View File
@@ -148,13 +148,11 @@
onLoad(async () => {
// 检查插件可用性
await checkPluginAvailable()
console.log('uniHaloPluginAvailable',uniHaloPluginAvailable.value)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
// 开始正常数据请求
handleGetCategory()
})
+1
View File
@@ -1,6 +1,7 @@
// 测试用的 iconfont,可生效
@import './iconfont.css';
@import './uhemoji-iconfont.css';
@import './uhemoji2-iconfont.css';
:root,
page {
File diff suppressed because one or more lines are too long