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

feat: 新增公告模块、数据加载hook与多项体验优化

- 新增公告全流程功能:首页公告轮播、公告列表页、公告详情页
- 新增统一数据加载状态hook useDataLoading与配套测试
- 重构配置加载逻辑,新增统一bootstrap静态配置拉取
- 新增维护模式、恋爱模块支持与验证码防刷功能
- 优化投票卡片、联系页、关于页UI样式
- 新增测试页面与全局玻璃卡片样式
- 修复markdown容器内边距与首页生命周期逻辑
This commit is contained in:
小莫唐尼
2026-09-04 02:57:25 +08:00
parent 902faa70e7
commit 5f22f4fa01
39 changed files with 4474 additions and 2244 deletions
+46 -22
View File
@@ -1,6 +1,7 @@
<script lang="ts" setup>
/**
* 关于项目页(源自旧项目 pagesA/about,新建复刻)
* 数字名片式设计:Hero 名片卡(渐变光斑透卡 + 徽章) + 彩色瓦片链接列表,点击复制
*/
import { onLoad } from '@dcloudio/uni-app'
@@ -11,12 +12,12 @@ definePage({
})
const links = [
{ title: '开源组织', value: '巷子工坊', copy: 'https://www.ialley.cn', tip: '巷子工坊官网已复制成功!' },
{ title: '开源作者', value: '小莫唐尼', copy: 'https://www.xiaoxiaomo.cn', tip: '作者主页地址已复制' },
{ title: '作者博客', value: 'https://blog.xiaoxiaomo.cn', copy: 'https://blog.xiaoxiaomo.cn', tip: '作者博客地址已复制' },
{ title: '文档地址', value: 'https://uni-halo.925i.cn', copy: 'https://uni-halo.925i.cn', tip: '项目码云仓库已复制' },
{ title: '码云仓库', value: 'https://gitee.com/ialley-workshop-open/uni-halo', copy: 'https://gitee.com/ialley-workshop-open/uni-halo', tip: '码云仓库地址已复制' },
{ title: 'Github', value: 'https://github.com/ialley-workshop-open/uni-halo', copy: 'https://github.com/ialley-workshop-open/uni-halo', tip: 'Github地址已复制' },
{ title: '开源组织', value: '巷子工坊', copy: 'https://www.ialley.cn', tip: '巷子工坊官网已复制成功!', tileColor: '#5c6bc0', tileLetter: '组' },
{ title: '开源作者', value: '小莫唐尼', copy: 'https://www.xiaoxiaomo.cn', tip: '作者主页地址已复制', tileColor: '#26a69a', tileLetter: '作' },
{ title: '作者博客', value: 'https://blog.xiaoxiaomo.cn', copy: 'https://blog.xiaoxiaomo.cn', tip: '作者博客地址已复制', tileColor: '#7e57c2', tileLetter: '博' },
{ title: '文档地址', value: 'https://uni-halo.925i.cn', copy: 'https://uni-halo.925i.cn', tip: '项目码云仓库已复制', tileColor: '#039be5', tileLetter: '文' },
{ title: '码云仓库', value: 'https://gitee.com/ialley-workshop-open/uni-halo', copy: 'https://gitee.com/ialley-workshop-open/uni-halo', tip: '码云仓库地址已复制', tileColor: '#c71d23', tileLetter: '码' },
{ title: 'Github', value: 'https://github.com/ialley-workshop-open/uni-halo', copy: 'https://github.com/ialley-workshop-open/uni-halo', tip: 'Github地址已复制', tileColor: '#24292f', tileLetter: 'G' },
]
function copyText(content: string, tips: string) {
@@ -35,31 +36,54 @@ onLoad(() => {
</script>
<template>
<view class="app-page box-border h-screen w-screen flex flex-col items-center bg-white pt-10">
<view class="logo mt-10 pt-10">
<image class="logo-img h-[160rpx] w-[160rpx] rounded-xl" src="https://uni-halo.925i.cn/logo.png" mode="aspectFill" />
</view>
<view class="mt-3 text-[36rpx] font-bold">
uni-halo
<view class="app-page box-border min-h-screen w-screen flex flex-col overflow-hidden bg-page px-4 pb-8 pt-6">
<!-- Hero 名片卡(主题色光斑透过毛玻璃形成柔和渐变) -->
<view class="hero-wrap relative">
<view class="absolute h-[220rpx] w-[220rpx] rounded-full bg-[rgba(185,228,36,0.32)] -right-8 -top-8" />
<view class="absolute top-[150rpx] h-[180rpx] w-[180rpx] rounded-full bg-[rgba(215,249,76,0.45)] -left-10" />
<view class="hero-card uh-global-card-glass relative flex flex-col items-center rounded-3xl px-6 pb-7 pt-10">
<image class="logo-img h-[140rpx] w-[140rpx] rounded-3xl shadow-lg" src="https://uni-halo.925i.cn/logo.png" mode="aspectFill" />
<view class="mt-4 text-[40rpx] text-gray-900 font-bold">
uni-halo
</view>
<view class="mt-3 flex items-center gap-2">
<text class="rounded-full bg-secondary px-3 py-1 text-[20rpx] text-[#4d7c0f]">AGPL-3.0 开源协议</text>
<text class="rounded-full bg-[#f6f3ee] px-3 py-1 text-[20rpx] text-gray-500">uni-app × Halo</text>
</view>
<view class="mt-4 text-center text-[24rpx] text-gray-500 leading-relaxed">
基于 uni-app 打造的 Halo 博客跨端客户端
</view>
</view>
</view>
<view class="list-group mt-12 w-full">
<!-- 相关链接 -->
<uh-section-title class="mb-3 mt-6 text-[30rpx]">
相关链接
</uh-section-title>
<view class="link-list uh-global-card-glass overflow-hidden rounded-2xl">
<view
v-for="link in links"
v-for="(link, index) in links"
:key="link.title"
class="list-item flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7"
class="link-item flex items-center gap-3 px-4 py-4"
:class="index < links.length - 1 ? 'border-b border-black/5' : ''"
@click="copyText(link.copy, link.tip)"
>
<text class="list-title w-[160rpx] shrink-0 text-[28rpx] text-[#303133]">{{ link.title }}</text>
<text class="list-value max-w-[480rpx] overflow-hidden text-ellipsis whitespace-nowrap text-right text-[24rpx] text-[#909399]">{{ link.value }}</text>
<view class="tile h-[76rpx] w-[76rpx] flex shrink-0 items-center justify-center rounded-xl border border-black/5" :style="{ backgroundColor: link.tileColor + '1A' }">
<text class="text-[30rpx] font-bold" :style="{ color: link.tileColor }">{{ link.tileLetter }}</text>
</view>
<view class="min-w-0 flex flex-1 flex-col">
<text class="text-[28rpx] text-gray-900 font-bold">{{ link.title }}</text>
<view class="mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
{{ link.value }}
</view>
</view>
<wd-icon name="copy" size="28rpx" color="#c8c2b4" class="shrink-0" />
</view>
</view>
<view class="copyright fixed bottom-0 left-0 box-border w-screen bg-white p-9 text-center text-[22rpx] text-[#909399]">
<view>根据 AGPL-3.0 协议开源</view>
<view class="mt-2">
2022 uni-halo 开源项目丨巷子工坊@小莫唐尼
</view>
<!-- 版权 -->
<view class="copyright mt-auto pt-8 text-center text-[22rpx] text-gray-400">
<view> 2022 uni-halo 开源项目丨巷子工坊@小莫唐尼 </view>
</view>
</view>
</template>
+52 -89
View File
@@ -27,7 +27,7 @@ const globalAppSettings = computed(() => settingStore.settings)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const tab = ref({ activeIndex: 0, list: ['按月份查看', '按年份查看'] })
const activeTabIndex = ref(0)
const queryParams = ref({ size: 10, page: 1 })
const result = ref<{ hasNext: boolean }>({ hasNext: false })
const cacheDataList = ref<IPost[]>([])
@@ -44,6 +44,17 @@ const loadMoreText = ref('加载中...')
const postLabelYearKey = 'content.halo.run/archive-year'
const postLabelMonthKey = 'content.halo.run/archive-month'
/** 卡片布局偏好 → 原子类(对应旧版 cardType 样式变体) */
const CARD_LAYOUTS: Record<string, { card: string, thumb: string, info: string }> = {
lr_image_text: { card: '', thumb: 'h-[170rpx] w-[200rpx]', info: 'w-0 flex-1 pl-5' },
lr_text_image: { card: '', thumb: 'order-2 h-[170rpx] w-[200rpx]', info: 'order-1 w-0 flex-1 pr-5' },
tb_image_text: { card: 'flex-col', thumb: 'h-[220rpx] w-full', info: 'w-full pt-3' },
tb_text_image: { card: 'flex-col', thumb: 'order-2 h-[220rpx] w-full', info: 'order-1 w-full pb-3' },
only_text: { card: '', thumb: 'hidden', info: 'py-1' },
}
const calcCardLayout = computed(() => CARD_LAYOUTS[globalAppSettings.value.layout.cardType] || CARD_LAYOUTS.lr_image_text)
/* ---------------- 数据处理 ---------------- */
/** 按 tab 分组文章 */
function handleGetPosts(list: IPost[]): Record<string, IPost[]> {
@@ -51,7 +62,7 @@ function handleGetPosts(list: IPost[]): Record<string, IPost[]> {
list.forEach((item) => {
const labels = item.metadata.labels || {}
let postItemKey = ''
if (tab.value.activeIndex === 0) {
if (activeTabIndex.value === 0) {
postItemKey = `${labels[postLabelYearKey]}-${labels[postLabelMonthKey]}`
}
else {
@@ -78,7 +89,7 @@ function handleGetShowDataList(posts: Record<string, IPost[]>): typeof dataList.
month: '',
posts: posts[key],
}
if (tab.value.activeIndex === 0) {
if (activeTabIndex.value === 0) {
const splitDate = key.split('-')
postData.year = splitDate[0]
postData.month = splitDate[1]
@@ -136,13 +147,14 @@ async function handleGetData() {
loadMoreText.value = '加载中...'
try {
const data = await getPostList({ ...queryParams.value })
result.value = { hasNext: data.hasNext }
const posts = handleGetPosts(data.items)
// getPostList 返回 IResponse<IPostListRes>,数据在 .data 下
const res = await getPostList({ ...queryParams.value })
result.value = { hasNext: res.data.hasNext }
const posts = handleGetPosts(res.data.items)
const showDataList = handleGetShowDataList(posts)
if (isLoadMore.value) {
cacheDataList.value = handleUniqueCacheDatalist([...cacheDataList.value, ...data.items])
cacheDataList.value = handleUniqueCacheDatalist([...cacheDataList.value, ...res.data.items])
// 合并增量数据
showDataList.forEach((item) => {
const find = dataList.value.find(x => x.key === item.key)
@@ -163,11 +175,11 @@ async function handleGetData() {
}
else {
dataList.value = showDataList
cacheDataList.value = data.items
cacheDataList.value = res.data.items
}
loading.value = 'success'
loadMoreText.value = data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
loadMoreText.value = res.data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}
catch (err) {
console.error(err)
@@ -180,8 +192,8 @@ async function handleGetData() {
}
}
function handleOnTabChange(index: number) {
tab.value.activeIndex = index
function handleOnTabChange(e: { index: number }) {
activeTabIndex.value = e.index
queryParams.value.page = 1
dataList.value = handleGetShowDataList(handleGetPosts(cacheDataList.value))
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
@@ -237,17 +249,14 @@ onReachBottom(() => {
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<!-- 顶部 tab -->
<view class="archive-tabs fixed inset-x-0 top-0 z-6 bg-white">
<wd-tabs
v-model="tab.activeIndex"
:tabs="tab.list.map(title => ({ title }))"
align="center"
@change="handleOnTabChange"
/>
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
<!-- 顶部 tab(玻璃吸顶,wd-tabs 需用 wd-tab 子组件声明页签) -->
<view class="archive-tabs uh-global-card-glass sticky top-0 z-10">
<wd-tabs v-model="activeTabIndex" align="center" custom-style="background: transparent;" @change="handleOnTabChange">
<wd-tab title="按月份查看" />
<wd-tab title="按年份查看" />
</wd-tabs>
</view>
<view class="h-[90rpx] w-screen" />
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap p-3">
@@ -256,105 +265,59 @@ onReachBottom(() => {
<!-- 内容区域 -->
<block v-else>
<view v-if="dataList.length === 0" class="list-empty h-screen w-screen flex items-center justify-center">
<view v-if="dataList.length === 0" class="list-empty min-h-[60vh] flex items-center justify-center">
<wd-empty :description="calcAuditModeEnabled ? '暂无归档的内容' : '暂无归档的文章'" />
</view>
<view v-else class="timeline mt-6 px-6">
<!-- 时间线 -->
<view v-else class="timeline px-4 pt-3">
<view v-for="(item, index) in dataList" :key="item.key" class="timeline-item flex">
<view class="timeline-left w-[160rpx] flex shrink-0 flex-col items-center">
<view class="timeline-dot mt-1 h-6 w-6 rounded-full" style="background-color: #64b5f6; box-shadow: 0 4rpx 12rpx rgb(100 181 246 / 40%);" />
<view v-if="index !== dataList.length - 1" class="timeline-line mt-1 w-0.5 flex-1 bg-[#e0e0e0]" />
<view class="timeline-left w-[96rpx] flex shrink-0 flex-col items-center">
<view class="timeline-dot mt-2 h-4 w-4 rounded-full bg-secondary shadow-[0_0_0_8rpx_rgba(215,249,76,0.3)]" />
<view v-if="index !== dataList.length - 1" class="timeline-line mt-2 w-[2rpx] flex-1 bg-black/5" />
</view>
<view class="timeline-content flex-1 pb-12 pl-6">
<view class="time mb-6 flex items-center font-bold">
<text class="time-text text-[30rpx]">{{ item.year }}</text>
<text v-if="tab.activeIndex === 0" class="time-text text-[30rpx]">{{ item.month }}</text>
<text class="time-count ml-3 text-[22rpx] text-[#999] font-normal"> {{ item.posts.length }} {{ calcAuditModeEnabled ? '内容' : '文章' }}</text>
<view class="timeline-content min-w-0 flex-1 pb-10 pl-5">
<view class="time mb-5 flex items-center gap-2">
<text class="text-[32rpx] text-gray-900 font-bold">{{ item.year }}</text>
<text v-if="activeTabIndex === 0" class="text-[32rpx] text-gray-900 font-bold">{{ item.month }}</text>
<text class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none"> {{ item.posts.length }} {{ calcAuditModeEnabled ? '内容' : '文章' }}</text>
</view>
<view v-if="item.posts.length !== 0">
<view
v-for="post in item.posts"
:key="post.metadata.name"
class="post mb-6 flex rounded-xl bg-white p-6 shadow-sm"
:class="[globalAppSettings.layout.cardType]"
class="post uh-global-card-glass mb-4 flex rounded-2xl p-4"
:class="calcCardLayout.card"
@click="handleToArticleDetail(post)"
>
<image class="post-thumbnail h-[170rpx] w-[200rpx] shrink-0 rounded-lg" :src="checkThumbnailUrl(post.spec.cover)" mode="aspectFill" lazy-load />
<view class="post-info w-0 flex-1 pl-5">
<view class="post-info-title text-overflow text-[28rpx] text-[#303133] font-bold">
<image class="post-thumbnail shrink-0 rounded-lg" :class="calcCardLayout.thumb" :src="checkThumbnailUrl(post.spec.cover)" mode="aspectFill" lazy-load />
<view class="post-info min-w-0" :class="calcCardLayout.info">
<view class="post-info-title overflow-hidden text-ellipsis whitespace-nowrap text-[28rpx] text-gray-900 font-bold">
{{ post.spec.title }}
</view>
<view class="post-info-summary line-clamp-2 mt-3 text-[24rpx] text-[#909399]">
<view class="post-info-summary line-clamp-2 mt-2 text-[24rpx] text-gray-400">
{{ post.status?.excerpt }}
</view>
<view class="post-info-time mt-3 text-[24rpx] text-[#909399]">
<view class="post-info-time mt-2 text-[24rpx] text-gray-400">
发布时间{{ formatTime(post.spec.publishTime) }}
</view>
</view>
</view>
</view>
<view v-else class="post-empty py-6 text-[26rpx] text-[#909399]">
<view v-else class="post-empty py-6 text-[26rpx] text-gray-400">
该日期下暂无归档文章
</view>
</view>
</view>
</view>
<view class="load-text pb-6 text-center text-[24rpx] text-[#999]">
<view class="load-text pb-6 text-center text-[24rpx] text-gray-400">
{{ loadMoreText }}
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
</view>
</block>
</view>
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.timeline {
.post {
&.tb_image_text,
&.tb_text_image {
flex-direction: column;
.post-thumbnail {
width: 100%;
height: 220rpx;
}
.post-info {
width: 100%;
padding-left: 0;
}
}
&.lr_text_image {
.post-thumbnail {
order: 2;
}
.post-info {
order: 1;
padding-left: 0;
padding-right: 24rpx;
}
}
&.only_text {
.post-thumbnail {
display: none;
}
.post-info {
padding: 6rpx;
}
}
}
}
</style>
File diff suppressed because it is too large Load Diff
+52 -29
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
/**
* 联系博主页(源自旧项目 pagesA/contact,新建复刻)
* 展示博主社交联系方式,点击复制
* 数字名片式设计:Hero 名片卡(渐变光斑透卡) + 品牌色字母瓦片联系方式列表,点击复制
*/
import { computed, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
@@ -42,6 +42,20 @@ const result = ref<{ key: string, name: string, value: string }[]>([
{ key: 'email', name: '邮箱地址', value: '' },
])
/** 平台瓦片(品牌色 + 字符);未覆盖的平台回退中性色与名称首字 */
const platformMeta: Record<string, { color: string, letter: string }> = {
qq: { color: '#12b7f5', letter: 'Q' },
wechat: { color: '#07c160', letter: '微' },
github: { color: '#24292f', letter: 'G' },
gitee: { color: '#c71d23', letter: 'G' },
bilibili: { color: '#fb7299', letter: 'B' },
csdn: { color: '#fc5531', letter: 'C' },
blog: { color: '#3f51b5', letter: '博' },
juejin: { color: '#1e80ff', letter: '掘' },
weibo: { color: '#e6162d', letter: 'W' },
email: { color: '#f57c00', letter: '@' },
}
const calcIsNotEmpty = computed(() => result.value.some(item => item.value !== ''))
function handleGetData() {
@@ -77,46 +91,55 @@ onLoad(() => {
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col items-center bg-white pt-[160rpx]">
<!-- 博主信息 -->
<view class="profile flex flex-col items-center p-9">
<view class="avatar relative box-border h-[170rpx] w-[170rpx] overflow-hidden border-6 border-white rounded-full shadow-sm">
<image class="avatar-img h-full w-full" :src="bloggerInfo.avatar" mode="aspectFill" />
</view>
<view class="nickname mt-6 text-[38rpx] font-bold">
{{ bloggerInfo.nickname }}
</view>
<view class="desc mt-6 text-center text-[26rpx] text-[#666]">
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
<view class="app-page box-border min-h-screen w-screen overflow-hidden bg-page px-4 pb-10 pt-6">
<!-- Hero 名片卡(主题色光斑透过毛玻璃形成柔和渐变) -->
<view class="hero-wrap relative">
<view class="absolute h-[220rpx] w-[220rpx] rounded-full bg-[rgba(185,228,36,0.32)] -right-8 -top-8" />
<view class="absolute top-[150rpx] h-[180rpx] w-[180rpx] rounded-full bg-[rgba(215,249,76,0.45)] -left-10" />
<view class="hero-card uh-global-card-glass relative flex flex-col items-center rounded-3xl px-6 pb-6 pt-10">
<view class="avatar relative box-border h-[150rpx] w-[150rpx] overflow-hidden border-5 border-white/90 rounded-full shadow-lg">
<image class="avatar-img h-full w-full" :src="bloggerInfo.avatar" mode="aspectFill" />
</view>
<view class="nickname mt-4 text-[38rpx] text-gray-900 font-bold">
{{ bloggerInfo.nickname }}
</view>
<view class="desc mt-2 px-4 text-center text-[26rpx] text-gray-500 leading-relaxed">
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
</view>
<view class="mt-5 w-full flex items-center justify-center gap-2 border-t border-black/5 pt-4">
<wd-icon name="copy" size="26rpx" color="#a8a294" />
<text class="text-2xs text-gray-400">点击卡片即可复制对应内容</text>
</view>
</view>
</view>
<!-- 联系方式列表 -->
<view class="contact box-border w-full px-12 pt-12" style="border-top: 2rpx solid #f2f2f2;">
<block v-if="calcIsNotEmpty">
<!-- 联系方式 -->
<block v-if="calcIsNotEmpty">
<uh-section-title class="mb-3 mt-6 text-[30rpx]">
联系方式
</uh-section-title>
<view class="flex flex-col gap-3">
<view
v-for="item in result.filter(i => i.value)"
:key="item.key"
class="item mt-6 box-border rounded-xl bg-[#fafafa] p-4"
class="item uh-global-card-glass flex items-center gap-3 rounded-2xl px-4 py-3"
@click="handleOnClick(item)"
>
<view class="left box-border w-[160rpx] flex items-center">
<text class="name text-[24rpx] text-[#555]">{{ item.name }}</text>
<view class="tile h-[76rpx] w-[76rpx] flex shrink-0 items-center justify-center rounded-xl" :style="{ backgroundColor: platformMeta[item.key]?.color || '#8a8a7a' }">
<text class="text-[30rpx] text-white font-bold">{{ platformMeta[item.key]?.letter || item.name.slice(0, 1) }}</text>
</view>
<view class="right box-border w-0 flex flex-1 flex-wrap items-center break-all pl-3 text-[24rpx] text-[#333]">
{{ item.value }}
<view class="min-w-0 flex flex-1 flex-col">
<text class="text-[24rpx] text-gray-400">{{ item.name }}</text>
<view class="mt-1 break-all text-[26rpx] text-gray-900 leading-snug">
{{ item.value }}
</view>
</view>
<wd-icon name="copy" size="28rpx" color="#c8c2b4" class="shrink-0" />
</view>
</block>
<view v-else class="empty pt-12">
<wd-empty description="暂无联系方式" />
</view>
</block>
<view v-else class="pt-12">
<wd-empty description="暂无联系方式" />
</view>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+48 -55
View File
@@ -320,10 +320,10 @@ onReachBottom(() => {
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
<!-- 顶部 tabs -->
<view class="tabs-wrap sticky top-0 z-10 bg-white px-6">
<wd-tabs v-model="activeTabIndex" align="left" @change="handleOnTabChange">
<view class="tabs-wrap uh-global-card-glass sticky top-0 z-10 px-6">
<wd-tabs v-model="activeTabIndex" align="left" custom-style="background: transparent;" @change="handleOnTabChange">
<wd-tab title="站点" />
<wd-tab v-if="!appConfigStore.auditModeEnabled" title="小程序" />
</wd-tabs>
@@ -342,43 +342,42 @@ onReachBottom(() => {
<wd-skeleton :row="5" :animated="true" />
</view>
<view v-else class="content pt-6" :class="{ 'bg-white': dataList.length !== 0 }">
<view v-else class="content pt-4">
<view v-if="dataList.length === 0" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="啊偶,博主还没有朋友呢~" />
</view>
<!-- 友链列表 -->
<view v-else class="link-list px-6">
<view v-for="(link, index) in dataList" :key="index">
<view v-else class="link-list flex flex-col gap-4 px-4 pb-4">
<view v-for="link in dataList" :key="link.metadata?.name || link.spec.displayName">
<!-- 色彩版 -->
<view
v-if="!globalAppSettings.links.useSimple"
class="info flex bg-white p-3"
:class="{ 'border-b-2 border-[#f5f5f5]': index !== dataList.length - 1 }"
class="info uh-global-card-glass flex rounded-2xl p-3"
@click="handleOnLinkEvent(link)"
>
<image class="link-logo h-[140rpx] w-[140rpx] shrink-0 rounded-xl" :src="link.spec.logo" mode="aspectFill" />
<view class="info-detail flex flex-1 flex-col justify-center pl-7">
<view class="link-card-name text-[30rpx] text-[#f44336] font-bold">
<text class="group-tag mr-3 rounded-md px-1.5 py-0.5 text-[20rpx] text-white font-normal" style="background: linear-gradient(135deg, #64b5f6, #2196f3);">{{ link.spec.groupName || '暂未分组' }}</text>
<view class="info-detail flex flex-1 flex-col justify-center pl-5">
<view class="link-card-name text-[30rpx] text-gray-900 font-bold">
<text class="group-tag mr-3 rounded-md bg-secondary px-1.5 py-0.5 text-[20rpx] text-[#4d7c0f] font-normal">{{ link.spec.groupName || '暂未分组' }}</text>
{{ link.spec.displayName }}
</view>
<view class="link-card-url mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#666]">
<view class="link-card-url mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
站点地址{{ link.spec.url }}
</view>
<view class="link-card-desc mt-2 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#303133]">
<view class="link-card-desc mt-2 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-600">
博客简介{{ link.spec.description || '这个博主很懒,没写简介~' }}
</view>
</view>
</view>
<!-- 简洁版 -->
<view v-else class="link-card mb-6 flex items-center rounded-xl bg-white p-6 shadow-sm" @click="handleOnLinkEvent(link)">
<image class="logo h-[80rpx] w-[80rpx] shrink-0 border-6 border-white rounded-xl" :src="link.spec.logo" mode="aspectFill" />
<view class="link-info flex-1 pl-6">
<view class="name text-[30rpx] text-[#303133] font-bold">
<view v-else class="link-card uh-global-card-glass flex items-center rounded-2xl p-4" @click="handleOnLinkEvent(link)">
<image class="logo h-[80rpx] w-[80rpx] shrink-0 border-4 border-white/90 rounded-xl" :src="link.spec.logo" mode="aspectFill" />
<view class="link-info flex-1 pl-5">
<view class="name text-[30rpx] text-gray-900 font-bold">
{{ link.spec.displayName }}
</view>
<view class="desc mt-3 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#909399]">
<view class="desc mt-2 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
{{ link.spec.description }}
</view>
</view>
@@ -388,39 +387,39 @@ onReachBottom(() => {
<!-- 悬浮按钮 -->
<view class="flot-buttons fixed bottom-[100rpx] right-8 z-999 flex flex-col gap-1.5">
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
<view class="fab-btn uh-global-card-glass h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
</view>
<view v-if="(haloPluginConfigs?.linksSubmitPlugin as { enabled?: boolean } | undefined)?.enabled" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="toSubmitLinkPage">
<wd-icon name="edit" size="20px" color="#ff9800" />
<view v-if="(haloPluginConfigs?.linksSubmitPlugin as { enabled?: boolean } | undefined)?.enabled" class="fab-btn uh-global-card-glass h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="toSubmitLinkPage">
<wd-icon name="edit" size="20px" color="#6b7280" />
</view>
</view>
<!-- 详情弹窗 -->
<wd-popup v-model="detail.show" position="center" custom-style="width:640rpx;border-radius:12rpx;">
<view v-if="detail.data" class="poup p-9">
<view v-if="detail.data" class="poup p-8">
<view class="info flex">
<image class="poup-logo h-[140rpx] w-[140rpx] shrink-0 rounded-full" :src="checkImageUrl(detail.data.spec.logo)" mode="aspectFill" />
<view class="poup-info ml-6 flex flex-1 flex-col justify-center">
<view class="poup-name text-[34rpx] font-bold">
<view class="poup-name text-[34rpx] text-gray-900 font-bold">
{{ detail.data.spec.displayName }}
</view>
<view class="poup-tag mt-2 text-[24rpx] text-[#999]">
<view class="poup-tag mt-2 text-[24rpx] text-gray-400">
{{ detail.data.spec.groupName }}
</view>
<view class="poup-link mt-3" @click="handleCopyLink(detail.data)">
<text class="poup-url text-[24rpx] text-[#ff9800]">{{ detail.data.spec.url }}</text>
<text class="poup-url rounded-lg bg-secondary px-2 py-1 text-[24rpx] text-[#4d7c0f]">{{ detail.data.spec.url }}</text>
</view>
</view>
</view>
<view class="poup-desc mt-5 text-[28rpx] text-[#555] leading-[1.6]">
<view class="poup-desc mt-5 text-[28rpx] text-gray-600 leading-[1.6]">
博客简介{{ detail.data.spec.description || '这个博主很懒,没写简介~' }}
</view>
<image class="poup-img mt-6 h-[320rpx] w-[568rpx] rounded-xl" :src="calcSiteThumbnail(detail.data.spec.url)" mode="aspectFill" />
<image class="poup-img mt-6 h-[320rpx] w-full rounded-xl" :src="calcSiteThumbnail(detail.data.spec.url)" mode="aspectFill" />
</view>
</wd-popup>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
<view class="load-text py-5 text-center text-[24rpx] text-gray-400">
{{ loadMoreText }}
</view>
</view>
@@ -450,15 +449,15 @@ onReachBottom(() => {
<view v-else class="mini-link-list flex-1 px-6 py-4">
<view v-for="group in miniGroups" :key="group.groupName || 'ungrouped'" class="group-item mb-8">
<view class="group-title mb-4 flex items-center">
<text class="mr-2 inline-block h-[28rpx] w-[8rpx] rounded-full" style="background-color:#2196f3;" />
<text class="text-[30rpx] text-[#303133] font-bold">{{ group.displayName || '未分组' }}</text>
<text class="ml-3 text-[24rpx] text-[#999]">{{ group.links.length }}</text>
<text class="mr-2 inline-block h-[28rpx] w-[8rpx] rounded-full bg-secondary" />
<text class="text-[30rpx] text-gray-900 font-bold">{{ group.displayName || '未分组' }}</text>
<text class="ml-3 text-[24rpx] text-gray-400">{{ group.links.length }}</text>
</view>
<view class="group-cards flex flex-col gap-4">
<view
v-for="link in group.links"
:key="link.metadata?.name"
class="mini-card flex items-center rounded-xl bg-white p-4 shadow-sm"
class="mini-card uh-global-card-glass flex items-center rounded-2xl p-4"
@click="handleOnMiniLinkEvent(link)"
>
<image
@@ -467,17 +466,17 @@ onReachBottom(() => {
mode="aspectFill"
/>
<view class="mini-info flex flex-1 flex-col pl-5">
<view class="mini-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#303133] font-bold">
<view class="mini-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-gray-900 font-bold">
{{ link.spec?.displayName }}
</view>
<view v-if="link.spec?.authorName" class="mini-author mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#999]">
<view v-if="link.spec?.authorName" class="mini-author mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
{{ link.spec.authorName }}
</view>
<view class="mini-desc mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#666]">
<view class="mini-desc mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-500">
{{ link.spec?.description || '暂无简介~' }}
</view>
</view>
<wd-icon name="arrow-right" size="16px" color="#c0c4cc" />
<wd-icon name="arrow-right" size="16px" color="#c8c2b4" />
</view>
</view>
</view>
@@ -485,7 +484,7 @@ onReachBottom(() => {
<!-- 申请收录悬浮按钮 -->
<view class="apply-btn-wrap fixed bottom-[100rpx] right-8 z-999">
<view class="apply-btn h-[88rpx] flex items-center rounded-full bg-[#2196f3] px-6 shadow-sm" @click="handleOpenApply">
<view class="apply-btn h-[88rpx] flex items-center rounded-full bg-gray-900 px-6" @click="handleOpenApply">
<wd-icon name="add" size="20px" color="#fff" />
<text class="ml-2 text-[26rpx] text-white">申请收录</text>
</view>
@@ -504,42 +503,42 @@ onReachBottom(() => {
@click="handlePreviewMiniProgramCode(miniDetail.data)"
@longpress="handleSaveMiniProgramCode(miniDetail.data)"
/>
<view class="code-tip mt-3 flex items-center text-[24rpx] text-[#999]">
<wd-icon name="picture" size="14px" color="#999" />
<view class="code-tip mt-3 flex items-center text-[24rpx] text-gray-400">
<wd-icon name="picture" size="14px" color="#a8a294" />
<text class="ml-1">点击预览长按保存太阳码</text>
</view>
</view>
<!-- 名称与分组 -->
<view class="mini-head mt-5 flex items-center">
<text class="mini-name text-[34rpx] text-[#303133] font-bold">{{ miniDetail.data.spec?.displayName }}</text>
<text v-if="miniDetail.data.spec?.groupName" class="group-tag ml-3 rounded-md px-2 py-0.5 text-[20rpx] text-white" style="background: linear-gradient(135deg, #64b5f6, #2196f3);">
<text class="mini-name text-[34rpx] text-gray-900 font-bold">{{ miniDetail.data.spec?.displayName }}</text>
<text v-if="miniDetail.data.spec?.groupName" class="group-tag ml-3 rounded-md bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f]">
{{ miniDetail.data.spec.groupName }}
</text>
</view>
<!-- 描述 -->
<view v-if="miniDetail.data.spec?.description" class="mini-desc mt-4 text-[28rpx] text-[#555] leading-[1.6]">
<view v-if="miniDetail.data.spec?.description" class="mini-desc mt-4 text-[28rpx] text-gray-600 leading-[1.6]">
{{ miniDetail.data.spec.description }}
</view>
<!-- 作者信息 -->
<view v-if="miniDetail.data.spec?.authorName || miniDetail.data.spec?.avatar || miniDetail.data.spec?.website" class="mini-author-info mt-5 flex items-center rounded-xl bg-[#f5f5f5] p-4">
<view v-if="miniDetail.data.spec?.authorName || miniDetail.data.spec?.avatar || miniDetail.data.spec?.website" class="mini-author-info mt-5 flex items-center rounded-xl bg-[#f6f3ee] p-4">
<image v-if="miniDetail.data.spec?.avatar" class="author-avatar h-[72rpx] w-[72rpx] shrink-0 rounded-full" :src="checkAvatarUrl(miniDetail.data.spec.avatar)" mode="aspectFill" />
<view class="author-detail ml-4 flex flex-1 flex-col">
<text v-if="miniDetail.data.spec?.authorName" class="author-name text-[28rpx] text-[#303133] font-medium">{{ miniDetail.data.spec.authorName }}</text>
<text v-if="miniDetail.data.spec?.website" class="author-website mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#999]" @click="handleCopyMiniProgramCode(miniDetail.data)">
<text v-if="miniDetail.data.spec?.authorName" class="author-name text-[28rpx] text-gray-900 font-medium">{{ miniDetail.data.spec.authorName }}</text>
<text v-if="miniDetail.data.spec?.website" class="author-website mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400" @click="handleCopyMiniProgramCode(miniDetail.data)">
网站{{ miniDetail.data.spec.website }}
</text>
</view>
</view>
<!-- 小程序地址 -->
<view v-if="miniDetail.data.spec?.link" class="mini-link mt-5 flex items-center justify-between rounded-xl bg-[#fff8f0] p-4">
<view class="link-text flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#ff9800]">
<view v-if="miniDetail.data.spec?.link" class="mini-link mt-5 flex items-center justify-between rounded-xl bg-secondary p-4">
<view class="link-text flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#4d7c0f]">
{{ miniDetail.data.spec.link }}
</view>
<text class="ml-3 shrink-0 text-[26rpx] text-[#ff9800]" @click="handleCopyMiniProgramCode(miniDetail.data)">复制</text>
<text class="ml-3 shrink-0 text-[26rpx] text-[#4d7c0f] font-bold" @click="handleCopyMiniProgramCode(miniDetail.data)">复制</text>
</view>
<!-- 预览图轮播 -->
@@ -559,9 +558,3 @@ onReachBottom(() => {
</template>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+45 -35
View File
@@ -4,8 +4,7 @@
* 情侣信息 + 恋爱计时 + 功能导航(恋爱故事/相册/清单)
*/
import { computed, onBeforeUnmount, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getLoveConfig } from '@/api/uni-halo'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
@@ -70,41 +69,45 @@ const loveWrapStyle = computed(() => ({
}))
/* ---------------- 数据加载 ---------------- */
async function handleGetLoveConfig() {
try {
const loveConfigRes = await getLoveConfig()
if (loveConfigRes) {
loveConfig.value = {
...loveConfig.value,
...loveConfigRes,
}
}
// 同时从 getConfigs 获取模块开关和图片配置
const appConfigs = appConfigStore.configs
const loveModuleConfig = appConfigs.loveConfig as Partial<ILoveConfigPage> | undefined
if (loveModuleConfig) {
loveConfig.value = {
...loveConfig.value,
pageImages: loveModuleConfig.pageImages || loveConfig.value.pageImages,
ourStory: loveModuleConfig.ourStory || loveConfig.value.ourStory,
lovePhoto: loveModuleConfig.lovePhoto || loveConfig.value.lovePhoto,
loveDaily: loveModuleConfig.loveDaily || loveConfig.value.loveDaily,
}
}
initList()
handleInitLoveDayCount()
/**
* 恋爱配置数据统一来自 appConfig store(静态配置一次性拉取,设计见
* .docs/static-config-unified-fetch-design.md):
* - loveConfig(公开 /love-config 内容: enabled/纪念日/恋人信息, bootstrap 已拉取)
* - configs.loveConfig(模块开关与页面图片, getConfigs 合成下发)
* 页面不再自行发起 /love-config 请求,也不再需要接口失败降级分支(store 兜底默认)。
*/
function syncLoveConfigFromStore() {
const storeLove = appConfigStore.loveConfig
const appConfigs = appConfigStore.configs
loveConfig.value = {
...loveConfig.value,
...(storeLove.loveDateTitle ? { loveDateTitle: storeLove.loveDateTitle } : {}),
...(storeLove.loveDate ? { loveDate: storeLove.loveDate } : {}),
...(storeLove.enabled !== undefined ? { enabled: storeLove.enabled } : {}),
loveInfo: {
...loveConfig.value.loveInfo,
...(storeLove.loveInfo || {}),
},
}
catch (e) {
console.error('获取恋爱配置失败', e)
// 降级:从旧配置读取
const appConfigs = appConfigStore.configs
const loveModuleConfig = appConfigs.loveConfig as ILoveConfigPage | undefined
if (loveModuleConfig) {
loveConfig.value = loveModuleConfig
initList()
handleInitLoveDayCount()
// 从 getConfigs.loveConfig 取模块开关与页面图片(缺省保留默认)
const loveModuleConfig = appConfigs.loveConfig as Partial<ILoveConfigPage> | undefined
if (loveModuleConfig) {
loveConfig.value = {
...loveConfig.value,
pageImages: loveModuleConfig.pageImages || loveConfig.value.pageImages,
ourStory: loveModuleConfig.ourStory || loveConfig.value.ourStory,
lovePhoto: loveModuleConfig.lovePhoto || loveConfig.value.lovePhoto,
loveDaily: loveModuleConfig.loveDaily || loveConfig.value.loveDaily,
}
}
initList()
// 未配置纪念日(loveDate 为空)不启动倒计时,避免 NaN
if (loveConfig.value.loveDate) {
handleInitLoveDayCount()
}
}
function initList() {
@@ -169,7 +172,14 @@ function handleToPage(pageName: string) {
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱日记' })
handleGetLoveConfig()
// 先用 store 已有数据立即渲染(index 启动已 bootstrap 或持久化缓存恢复)
syncLoveConfigFromStore()
})
// 每次进入页面:静态配置 TTL 内不重复请求(bootstrap 内部判定),刷新后重新合成视图
onShow(async () => {
await appConfigStore.bootstrap()
syncLoveConfigFromStore()
})
onBeforeUnmount(() => {
+187 -166
View File
@@ -2,10 +2,11 @@
/**
* 瞬间详情页(源自旧项目 pagesA/moment-detail,新建复刻)
* 展示瞬间内容(mp-html) + 图片/音频/视频附件
* 设计:日记页(大字日期刊头 + 宽松留白),与列表页的信息流形成两种性格
* 试点:useDataLoading 状态机接管加载四态
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getMomentByName } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
@@ -13,12 +14,15 @@ import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
import { markdownConfig } from '@/config/markdown'
import { useDataLoading } from '@/hooks/useDataLoading'
import type { IMoment } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '瞬间详情',
enablePullDownRefresh: true,
// 下拉/回弹露出的窗口底色对齐页面底色
backgroundColor: '#f6f3ee',
},
})
@@ -42,16 +46,17 @@ const siteName = computed(() => {
})
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryName = ref('')
const moment = ref<(IMoment & {
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null)
/** 瞬间卡片(medium 按类型拆为 images/videos/audios + 正文 tag 清理) */
type MomentCard = IMoment & {
images?: { type?: string, url: string }[]
videos?: { id?: string, url: string }[]
audios?: { type?: string, url: string }[]
spec: IMoment['spec'] & { newHtml?: string }
}) | null>(null)
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null)
}
/** 移除 tag 链接 */
function removeTagLinksCompletely(htmlString: string): string {
@@ -59,56 +64,63 @@ function removeTagLinksCompletely(htmlString: string): string {
return htmlString.replace(regex, '')
}
function tagColor(): string {
if (!calcUseTagRandomColor.value)
return 'blue'
return randomTagColor()
/** 瞬间详情 → 展示卡片(作者兜底 + medium 拆分 + tag 清理) */
function buildMomentCard(res: IMoment): MomentCard {
const medium = (res.spec.content?.medium || [])
.map(x => ({ ...x, url: x.url || '' }))
const owner = res.owner
return {
...res,
// 无顶层 owner(如个别历史接口)时兜底为博主信息
owner: owner?.displayName
? owner
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
spec: {
...res.spec,
newHtml: removeTagLinksCompletely(res.spec.content?.html || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
audios: medium.filter(x => x.type === 'AUDIO'),
}
}
function formatTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
try {
/* ---------------- 数据加载(useDataLoading 试点:状态由 hook 接管) ---------------- */
const { data: moment, status, run: loadMoment } = useDataLoading(
async (): Promise<MomentCard> => {
const res = await getMomentByName(queryName.value)
uni.setNavigationBarTitle({ title: '瞬间详情' })
return buildMomentCard(res.data)
},
{
onSuccess: (card) => {
nextTick(() => {
createVideoContexts(card.videos || [])
})
},
onError: () => {
uni.setNavigationBarTitle({ title: '瞬间详情' })
},
},
)
const medium = (res.data.spec.content?.medium || [])
.map(x => ({ ...x, url: x.url || '' }))
const owner = res.data.owner
const tempResult = {
...res.data,
// 无顶层 owner(如个别历史接口)时兜底为博主信息
owner: owner?.displayName
? owner
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
spec: {
...res.data.spec,
newHtml: removeTagLinksCompletely(res.data.spec.content?.html || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
audios: medium.filter(x => x.type === 'AUDIO'),
}
moment.value = tempResult
loading.value = 'success'
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
const calcTagColors = computed(() => {
const tags = moment.value?.spec.tags || []
return tags.map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f'))
})
nextTick(() => {
createVideoContexts(tempResult.videos || [])
})
}
catch (err) {
console.error('获取瞬间详情失败', err)
loading.value = 'error'
}
finally {
uni.stopPullDownRefresh()
}
}
/** 刊头大字日期(如 09月03日) */
const calcMastheadDate = computed(() => {
const time = moment.value?.spec.releaseTime
return time ? formatTimeUtil({ d: time, f: 'MM月dd日' }) : ''
})
/** 刊头辅助信息(如 2026年 · 星期四) */
const calcMastheadMeta = computed(() => {
const time = moment.value?.spec.releaseTime
return time ? formatTimeUtil({ d: time, f: 'yyyy年 · 星期w' }) : ''
})
/* ---------------- 视频互斥 ---------------- */
function createVideoContexts(videos: { id?: string }[]) {
@@ -165,13 +177,14 @@ function handleToTopPage(duration = 500) {
onLoad((options) => {
uni.setNavigationBarTitle({ title: '瞬间加载中...' })
queryName.value = options?.name || ''
handleGetData()
loadMoment()
})
onPullDownRefresh(() => {
onPullDownRefresh(async () => {
videoContexts.value = {}
currentVideoId.value = null
handleGetData()
await loadMoment()
uni.stopPullDownRefresh()
})
onShareAppMessage(() => ({
@@ -186,131 +199,139 @@ onShareTimeline(() => ({
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap h-screen bg-white px-6">
<wd-skeleton :row="3" :animated="true" />
</view>
<view class="app-page box-border min-h-screen w-screen bg-page px-4 pb-8 pt-4">
<!-- 状态区(加载中/失败可重试/) -->
<uh-data-loading
v-if="status !== 'success'"
:loading-status="status"
min-height="60vh"
error-text="瞬间内容加载失败"
empty-text="瞬间不存在或已被删除"
@refresh="loadMoment"
/>
<block v-else>
<view v-if="moment" class="moment-card flex flex-col gap-6 p-6">
<!-- 用户信息 -->
<view class="card flex items-center rounded-xl bg-white p-6 shadow-sm">
<image class="avatar h-[80rpx] w-[80rpx] shrink-0 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="nickname ml-3">
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1.5 text-[24rpx] text-[#666]">
{{ formatTime(moment.spec.releaseTime) }}
</view>
<!-- 互动数据(点赞/评论) -->
<view v-if="moment.stats && ((moment.stats.totalComment ?? 0) > 0 || (moment.stats.upvote ?? 0) > 0)" class="stats mt-1.5 flex items-center gap-6 text-[24rpx] text-[#8a919e]">
<view class="flex items-center gap-1">
<wd-icon name="heart" size="13px" color="#f08585" />
<text>{{ moment.stats.upvote || 0 }}</text>
</view>
<view class="flex items-center gap-1">
<wd-icon name="message" size="13px" color="#9aa3b2" />
<text>{{ moment.stats.totalComment || 0 }}</text>
</view>
</view>
<!-- 瞬间内页(日记式:大字日期刊头 + 阅读正文 + 媒体 + 互动脚注) -->
<view v-else-if="moment" class="moment-card uh-shadow-xs overflow-hidden rounded-[28rpx] bg-white">
<!-- 刊头:大字日期 -->
<view v-if="moment.spec.releaseTime" class="px-7 pt-7">
<view class="text-[56rpx] text-gray-900 font-bold leading-tight">
{{ calcMastheadDate }}
</view>
<view class="mt-1 text-[24rpx] text-gray-400">
{{ calcMastheadMeta }}
</view>
<view class="mt-4 h-[8rpx] w-[96rpx] rounded-full bg-secondary" />
</view>
<!-- 作者 -->
<view class="flex items-center px-7 pt-6">
<image class="h-[80rpx] w-[80rpx] shrink-0 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="ml-3 flex flex-col">
<view class="text-[30rpx] text-[#5c7c0f] font-bold">
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="mt-0.5 text-[22rpx] text-gray-400">
记录了这美好的一刻
</view>
</view>
</view>
<!-- 标签 -->
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<text class="tags-label text-[26rpx] text-[#606266]">标签列表</text>
<text v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="tag mr-4 text-[24rpx]" :style="{ color: tagColor() }">
{{ tag }}
</text>
<!-- 正文 -->
<view class="content px-7 pt-5">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</view>
<!-- 图片附件 -->
<view v-if="moment.images && moment.images.length !== 0" class="px-7 pt-6">
<view class="images flex flex-wrap items-start">
<view
v-for="(image, mediumIndex) in moment.images"
:key="mediumIndex"
class="image-item box-border p-1.5"
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')"
>
<image
mode="aspectFill"
class="image-src h-full w-full rounded-lg"
:src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])"
/>
</view>
</view>
</view>
<!-- 内容 -->
<view class="card overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
<!-- 音频附件 -->
<view v-if="moment.audios && moment.audios.length !== 0" class="px-7 pt-6">
<view class="audio-list flex flex-col gap-3">
<uh-audio-player
v-for="audio in moment.audios"
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${siteName}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
</view>
<!-- 图片附件 -->
<view v-if="moment.images && moment.images.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
图片附件
</view>
<view class="images flex flex-wrap items-start pt-6" :class="`images-${moment.images.length}`">
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex" class="image-item box-border p-1.5" :class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
<image
mode="aspectFill"
class="image-src h-full w-full rounded-lg"
:src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])"
/>
</view>
</view>
</view>
<!-- 音频附件 -->
<view v-if="moment.audios && moment.audios.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
音频附件
</view>
<view class="audio-list flex flex-col gap-3 pt-3">
<uh-audio-player
v-for="audio in moment.audios"
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${siteName}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
</view>
<!-- 视频附件 -->
<view v-if="moment.videos && moment.videos.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
视频附件
</view>
<view class="video-list mt-6 w-full flex flex-col gap-3">
<video
v-for="(video, index) in moment.videos"
:id="`video_${video.id}`"
:key="index"
class="video-src h-[400rpx] w-full rounded-xl"
:src="video.url"
:show-mute-btn="true"
:controls="true"
:show-center-play-btn="true"
:enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')"
@pause="onVideoPause(video.id || '')"
@ended="onVideoEnded"
/>
</view>
<!-- 视频附件 -->
<view v-if="moment.videos && moment.videos.length !== 0" class="px-7 pt-6">
<view class="video-list w-full flex flex-col gap-3">
<video
v-for="(video, index) in moment.videos"
:id="`video_${video.id}`"
:key="index"
class="video-src h-[400rpx] w-full rounded-xl"
:src="video.url"
:show-mute-btn="true"
:controls="true"
:show-center-play-btn="true"
:enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')"
@pause="onVideoPause(video.id || '')"
@ended="onVideoEnded"
/>
</view>
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
<!-- 标签 -->
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-2 px-7 pt-6">
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="rounded-full bg-black/5 px-3 py-1 text-[22rpx] font-bold" :style="{ color: calcTagColors[tagIndex] }">
# {{ tag }}
</view>
</view>
</block>
<!-- 互动脚注(点赞/评论) -->
<view v-if="moment.stats" class="mx-7 mt-6 flex items-center justify-end gap-5 border-t border-black/5 py-4 text-[24rpx] text-gray-400">
<view class="flex items-center gap-1">
<wd-icon name="heart" size="13px" color="#f08585" />
<text>{{ moment.stats.upvote || 0 }}</text>
</view>
<view class="flex items-center gap-1">
<wd-icon name="message" size="13px" color="#9aa3b2" />
<text>{{ moment.stats.totalComment || 0 }}</text>
</view>
</view>
<view v-else class="h-7" />
</view>
<!-- 回顶 -->
<view class="to-top-btn uh-shadow-xs fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
</view>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
@@ -0,0 +1,337 @@
<script lang="ts" setup>
/**
* 瞬间详情页(源自旧项目 pagesA/moment-detail,新建复刻)
* 展示瞬间内容(mp-html) + 图片/音频/视频附件
* 试点:useDataLoading 状态机接管加载四态 + 壁纸光斑玻璃卡设计
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getMomentByName } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
import { markdownConfig } from '@/config/markdown'
import { useDataLoading } from '@/hooks/useDataLoading'
import type { IMoment } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '瞬间详情',
enablePullDownRefresh: true,
// 下拉/回弹露出的窗口底色对齐页面底色,壁纸光斑由页面内提供
backgroundColor: '#f6f3ee',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
const siteName = computed(() => {
const appInfo = haloConfigs.value.appConfig?.appInfo as { name?: string } | undefined
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
})
/* ---------------- 状态 ---------------- */
const queryName = ref('')
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null)
/** 瞬间卡片(medium 按类型拆为 images/videos/audios + 正文 tag 清理) */
type MomentCard = IMoment & {
images?: { type?: string, url: string }[]
videos?: { id?: string, url: string }[]
audios?: { type?: string, url: string }[]
spec: IMoment['spec'] & { newHtml?: string }
}
/** 移除 tag 链接 */
function removeTagLinksCompletely(htmlString: string): string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
return htmlString.replace(regex, '')
}
/** 瞬间详情 → 展示卡片(作者兜底 + medium 拆分 + tag 清理) */
function buildMomentCard(res: IMoment): MomentCard {
const medium = (res.spec.content?.medium || [])
.map(x => ({ ...x, url: x.url || '' }))
const owner = res.owner
return {
...res,
// 无顶层 owner(如个别历史接口)时兜底为博主信息
owner: owner?.displayName
? owner
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
spec: {
...res.spec,
newHtml: removeTagLinksCompletely(res.spec.content?.html || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
audios: medium.filter(x => x.type === 'AUDIO'),
}
}
/* ---------------- 数据加载(useDataLoading 试点:状态由 hook 接管) ---------------- */
const { data: moment, status, run: loadMoment } = useDataLoading(
async (): Promise<MomentCard> => {
const res = await getMomentByName(queryName.value)
uni.setNavigationBarTitle({ title: '瞬间详情' })
return buildMomentCard(res.data)
},
{
onSuccess: (card) => {
nextTick(() => {
createVideoContexts(card.videos || [])
})
},
onError: () => {
uni.setNavigationBarTitle({ title: '瞬间详情' })
},
},
)
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
const calcTagColors = computed(() => {
const tags = moment.value?.spec.tags || []
return tags.map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f'))
})
function formatTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 视频互斥 ---------------- */
function createVideoContexts(videos: { id?: string }[]) {
stopAllVideos()
videos.forEach((item) => {
if (item.id) {
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
}
})
}
function stopAllVideos(excludesVideoId: string | null = null) {
Object.keys(videoContexts.value).forEach((videoId) => {
if (!excludesVideoId || excludesVideoId !== videoId) {
videoContexts.value[videoId]?.pause()
}
})
}
function onVideoPlay(videoId: string) {
currentVideoId.value = videoId
stopAllVideos(videoId)
}
function onVideoPause(videoId: string) {
if (currentVideoId.value === videoId) {
currentVideoId.value = null
}
}
function onVideoEnded() {
currentVideoId.value = null
}
/* ---------------- 交互 ---------------- */
function handlePreview(index: number, list: { url: string }[]) {
uni.previewImage({
current: index,
urls: list.map(item => item.url),
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
uni.setNavigationBarTitle({ title: '瞬间加载中...' })
queryName.value = options?.name || ''
loadMoment()
})
onPullDownRefresh(async () => {
videoContexts.value = {}
currentVideoId.value = null
await loadMoment()
uni.stopPullDownRefresh()
})
onShareAppMessage(() => ({
path: `/pages-blog/moment-detail/moment-detail?name=${moment.value?.metadata.name}`,
title: moment.value?.owner?.displayName || '',
}))
onShareTimeline(() => ({
title: moment.value?.owner?.displayName || '',
query: moment.value ? `name=${moment.value.metadata.name}` : '',
}))
</script>
<template>
<view class="app-page relative box-border min-h-screen w-screen bg-page pb-8">
<!-- 壁纸(柔和光斑为卡片毛玻璃取色,固定不随滚动) -->
<view class="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<view class="absolute h-[420rpx] w-[420rpx] rounded-full bg-[rgba(103,164,242,0.16)] -left-[120rpx] -top-[60rpx]" />
<view class="absolute top-[240rpx] h-[360rpx] w-[360rpx] rounded-full bg-[rgba(244,143,177,0.15)] -right-[130rpx]" />
<view class="absolute top-[760rpx] h-[400rpx] w-[400rpx] rounded-full bg-[rgba(179,157,219,0.14)] -left-[150rpx]" />
<view class="absolute top-[1220rpx] h-[380rpx] w-[380rpx] rounded-full bg-[rgba(185,228,36,0.15)] -right-[120rpx]" />
</view>
<!-- 状态区(加载中/失败可重试/空) -->
<uh-data-loading
v-if="status !== 'success'"
class="relative z-1"
:loading-status="status"
min-height="60vh"
error-text="瞬间内容加载失败"
empty-text="瞬间不存在或已被删除"
@refresh="loadMoment"
/>
<!-- 瞬间内容(悬浮玻璃卡) -->
<view v-else-if="moment" class="moment-card uh-global-card-glass relative z-1 mx-4 mt-5 flex flex-col rounded-[32rpx] p-5">
<!-- 用户区 -->
<view class="user flex items-center gap-3">
<image class="h-[96rpx] w-[96rpx] shrink-0 border-4 border-white/90 rounded-full shadow-lg" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="nickname min-w-0 flex flex-1 flex-col">
<view class="nickname-text text-[32rpx] text-gray-900 font-bold">
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1 text-[24rpx] text-gray-400">
{{ formatTime(moment.spec.releaseTime) }}
</view>
</view>
<!-- 互动数据(点赞/评论) -->
<view v-if="moment.stats && ((moment.stats.totalComment ?? 0) > 0 || (moment.stats.upvote ?? 0) > 0)" class="stats flex shrink-0 items-center gap-2">
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
<wd-icon name="heart" size="12px" color="#f08585" />
<text>{{ moment.stats.upvote || 0 }}</text>
</view>
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
<wd-icon name="message" size="12px" color="#7f8ea3" />
<text>{{ moment.stats.totalComment || 0 }}</text>
</view>
</view>
</view>
<!-- 标签 -->
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags mt-4 flex flex-wrap gap-2">
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="rounded-full bg-black/5 px-3 py-1 text-[22rpx] font-bold" :style="{ color: calcTagColors[tagIndex] }">
# {{ tag }}
</view>
</view>
<!-- 内容 -->
<view class="content mt-4">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</view>
<!-- 图片附件 -->
<view v-if="moment.images && moment.images.length !== 0" class="mt-5">
<view class="mb-3 flex items-center gap-2">
<view class="h-[24rpx] w-[6rpx] rounded-full bg-secondary" />
<text class="text-[26rpx] text-gray-500 font-bold">图片附件</text>
</view>
<view class="images flex flex-wrap items-start">
<view
v-for="(image, mediumIndex) in moment.images"
:key="mediumIndex"
class="image-item box-border p-1.5"
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')"
>
<image
mode="aspectFill"
class="image-src h-full w-full rounded-lg"
:src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])"
/>
</view>
</view>
</view>
<!-- 音频附件 -->
<view v-if="moment.audios && moment.audios.length !== 0" class="mt-5">
<view class="mb-3 flex items-center gap-2">
<view class="h-[24rpx] w-[6rpx] rounded-full bg-secondary" />
<text class="text-[26rpx] text-gray-500 font-bold">音频附件</text>
</view>
<view class="audio-list flex flex-col gap-3">
<uh-audio-player
v-for="audio in moment.audios"
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${siteName}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
</view>
<!-- 视频附件 -->
<view v-if="moment.videos && moment.videos.length !== 0" class="mt-5">
<view class="mb-3 flex items-center gap-2">
<view class="h-[24rpx] w-[6rpx] rounded-full bg-secondary" />
<text class="text-[26rpx] text-gray-500 font-bold">视频附件</text>
</view>
<view class="video-list w-full flex flex-col gap-3">
<video
v-for="(video, index) in moment.videos"
:id="`video_${video.id}`"
:key="index"
class="video-src h-[400rpx] w-full rounded-xl"
:src="video.url"
:show-mute-btn="true"
:controls="true"
:show-center-play-btn="true"
:enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')"
@pause="onVideoPause(video.id || '')"
@ended="onVideoEnded"
/>
</view>
</view>
</view>
<!-- 回顶 -->
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
</view>
</view>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts" setup>
/**
* 公告详情页(plugin-uni-halo 通知公告,2026-09-03 客户端接入)
* 公开 GET /notices/{name} 返回完整 Notice extension(metadata+spec,spec 内嵌
* typeDisplayName/typeColor);正文 content 为富文本 HTML,mp-html 渲染。
* 不存在/删除中返回 404 → 空态提示。UIUX 见 .docs/notice-module-client-design.md
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getNoticeDetail } from '@/api/uni-halo'
import { checkIsUrl } from '@/utils/url'
import { markdownConfig } from '@/config/markdown'
import type { INoticeDetail } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '公告详情',
},
})
const loading = ref(true)
const notFound = ref(false)
const detail = ref<INoticeDetail | null>(null)
const spec = computed(() => detail.value?.spec)
const content = computed(() => spec.value?.content || '')
const title = computed(() => spec.value?.title || '')
const typeColor = computed(() => spec.value?.typeColor || '')
const typeDisplayName = computed(() => spec.value?.typeDisplayName || '')
const cover = computed(() => spec.value?.cover || '')
const publishTime = computed(() => formatDate(spec.value?.publishTime))
const hasLink = computed(() => !!spec.value?.link && checkIsUrl(spec.value?.link || ''))
function formatDate(value?: string): string {
if (!value)
return ''
const date = new Date(value)
if (Number.isNaN(date.getTime()))
return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function handleBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
}
else {
uni.switchTab({ url: '/pages/tabbar/home/home' })
}
}
/** 外链 → 复用 website 承载页 */
function handleToExternal() {
if (!spec.value?.link)
return
uni.navigateTo({
url: `/pages-blog/website/website?data=${JSON.stringify({
title: title.value || '公告原文',
url: encodeURIComponent(spec.value.link),
})}`,
})
}
onLoad(async (options) => {
const name = options?.name || ''
if (!name) {
notFound.value = true
loading.value = false
return
}
try {
const res = await getNoticeDetail(name)
detail.value = res.data || null
if (!detail.value?.spec) {
notFound.value = true
}
}
catch (err) {
console.error('公告详情加载失败', err)
const code = (err as { code?: number }).code
notFound.value = code === 404
if (code !== 404) {
uni.showToast({ icon: 'none', title: '公告加载失败' })
}
}
finally {
loading.value = false
}
})
</script>
<template>
<view class="notice-detail min-h-screen w-screen bg-white pb-12">
<!-- 加载中 -->
<view v-if="loading" class="flex flex-col items-center justify-center py-40">
<text class="text-[26rpx] text-[#999]">
加载中...
</text>
</view>
<!-- 不存在/已下线 -->
<view v-else-if="notFound" class="flex flex-col items-center justify-center py-40">
<text class="text-[60rpx]">
🕳
</text>
<text class="mt-6 text-[26rpx] text-[#999]">
公告不存在或已下线
</text>
<view class="mt-10">
<wd-button size="small" type="primary" plain @click="handleBack">
返回
</wd-button>
</view>
</view>
<!-- 正文 -->
<view v-else class="px-6 py-6">
<image
v-if="cover"
class="mb-5 h-[320rpx] w-full rounded-xl"
:src="cover"
mode="aspectFill"
/>
<view class="text-[36rpx] font-bold leading-snug text-[#222]">
{{ title }}
</view>
<view class="mt-3 flex items-center gap-2">
<view
v-if="typeDisplayName"
class="rounded px-2 py-0.5 text-[20rpx]"
:style="{
color: typeColor || '#f83856',
backgroundColor: typeColor ? `${typeColor}1a` : '#fdeef1',
}"
>
{{ typeDisplayName }}
</view>
<text class="text-[22rpx] text-[#bbb]">
{{ publishTime }}
</text>
</view>
<view class="mt-4 border-t border-[#f0f0f0] pt-5">
<mp-html
:content="content"
lazy-load
:domain="markdownConfig.domain ?? ''"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:show-line-number="false"
copy-by-long-press
/>
</view>
<view v-if="hasLink" class="mt-10">
<wd-button type="primary" block round @click="handleToExternal">
查看原文
</wd-button>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.notice-detail {
:deep(img) {
max-width: 100%;
border-radius: 8rpx;
}
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script lang="ts" setup>
/**
* 公告中心·列表页(plugin-uni-halo 通知公告,2026-09-03 客户端接入)
* 公开接口 GET /notices 仅分页(脱敏不含 content);公告量小,页面一次拉取
* size=100 后本地做「类型筛选 + 排序(最新/最早/按类型分组)」,触底续拉下一页。
* UIUX 见 .docs/notice-module-client-design.md
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getNotices } from '@/api/uni-halo'
import type { INoticeListVo } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '公告中心',
enablePullDownRefresh: true,
},
})
const PAGE_SIZE = 100
const loading = ref(false)
const allItems = ref<INoticeListVo[]>([])
const page = ref(1)
const total = ref(0)
const activeType = ref('')
type SortKey = 'latest' | 'earliest' | 'group'
const sortKey = ref<SortKey>('latest')
const SORT_OPTIONS: Array<{ id: SortKey, label: string }> = [
{ id: 'latest', label: '最新在前' },
{ id: 'earliest', label: '最早在前' },
{ id: 'group', label: '按类型分组' },
]
/* ---------------- 类型 chips(从已加载列表聚合) ---------------- */
const typeOptions = computed(() => {
const map = new Map<string, { typeName: string, typeDisplayName: string, typeColor: string }>()
for (const item of allItems.value) {
const key = item.typeName || ''
const display = item.typeDisplayName || '未分类'
if (!map.has(key)) {
map.set(key, { typeName: key, typeDisplayName: display, typeColor: item.typeColor || '' })
}
}
return Array.from(map.values())
})
/* ---------------- 时间与排序 ---------------- */
function formatDate(value?: string): string {
if (!value)
return ''
const date = new Date(value)
if (Number.isNaN(date.getTime()))
return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function timeOf(item: INoticeListVo): number {
const time = item.publishTime ? new Date(item.publishTime).getTime() : NaN
return Number.isNaN(time) ? 0 : time
}
/** 展示列表:先按类型筛选,再按排序键排序(时间倒序/正序/按类型分组) */
const displayList = computed(() => {
let list = allItems.value
if (activeType.value !== '') {
list = list.filter(item => (item.typeName || '') === activeType.value)
}
if (sortKey.value === 'latest') {
list = [...list].sort((a, b) => timeOf(b) - timeOf(a))
}
else if (sortKey.value === 'earliest') {
list = [...list].sort((a, b) => timeOf(a) - timeOf(b))
}
else {
// 按类型分组:未分类排最后,组内按发布时间倒序
const groupOrder = new Map<string, number>()
for (const item of allItems.value) {
const key = item.typeName || ''
if (!groupOrder.has(key)) {
groupOrder.set(key, groupOrder.size)
}
}
list = [...list].sort((a, b) => {
const ka = a.typeName || ''
const kb = b.typeName || ''
const ga = groupOrder.get(ka) ?? Number.MAX_SAFE_INTEGER
const gb = groupOrder.get(kb) ?? Number.MAX_SAFE_INTEGER
if (ga !== gb)
return ga - gb
return timeOf(b) - timeOf(a)
})
}
return list
})
/* ---------------- 数据加载 ---------------- */
async function loadNotices(reset: boolean) {
if (loading.value)
return
loading.value = true
try {
const target = reset ? 1 : page.value + 1
const res = await getNotices({ page: target, size: PAGE_SIZE })
const body = res.data
const items = body?.items || []
if (reset) {
allItems.value = items
page.value = 1
}
else {
allItems.value = [...allItems.value, ...items]
page.value = target
}
total.value = body?.total ?? allItems.value.length
}
catch (err) {
console.error('公告列表加载失败', err)
uni.showToast({ icon: 'none', title: '公告加载失败' })
}
finally {
loading.value = false
}
}
function loadMore() {
if (loading.value || allItems.value.length >= total.value)
return
loadNotices(false)
}
onLoad(() => {
loadNotices(true)
})
onReachBottom(() => {
loadMore()
})
onPullDownRefresh(async () => {
await loadNotices(true)
uni.stopPullDownRefresh()
})
/* ---------------- 跳转 ---------------- */
function handleToDetail(item: INoticeListVo) {
if (!item.name)
return
uni.navigateTo({ url: `/pages-blog/notice/detail?name=${item.name}` })
}
</script>
<template>
<view class="notice-page min-h-screen w-screen bg-[#f7f8fa]">
<!-- 类型筛选(横滑 chips) -->
<scroll-view scroll-x class="type-bar sticky top-0 z-10 w-full bg-white px-3 py-2" :show-scrollbar="false">
<view class="flex items-center gap-2 whitespace-nowrap">
<view
class="rounded-full px-4 py-1.5 text-[24rpx]"
:class="activeType === '' ? 'bg-[#f83856] text-white' : 'bg-[#f0f0f0] text-[#666]'"
@click="activeType = ''"
>
全部
</view>
<view
v-for="type in typeOptions"
:key="type.typeName || '__none__'"
class="flex items-center gap-1 rounded-full px-4 py-1.5 text-[24rpx]"
:class="activeType === type.typeName ? 'bg-[#f83856] text-white' : 'bg-[#f0f0f0] text-[#666]'"
@click="activeType = activeType === type.typeName ? '' : type.typeName"
>
<view v-if="type.typeColor" class="h-2 w-2 rounded-full" :style="{ backgroundColor: type.typeColor }" />
<text>{{ type.typeDisplayName }}</text>
</view>
</view>
</scroll-view>
<!-- 排序 -->
<view class="sort-bar flex items-center gap-2 px-4 py-2">
<view
v-for="opt in SORT_OPTIONS"
:key="opt.id"
class="rounded-full px-3 py-1 text-[22rpx]"
:class="sortKey === opt.id ? 'bg-[#fdeef1] text-[#f83856]' : 'text-[#999]'"
@click="sortKey = opt.id"
>
{{ opt.label }}
</view>
</view>
<!-- 列表 -->
<view v-if="displayList.length > 0" class="px-3 pb-6">
<view
v-for="item in displayList"
:key="item.name"
class="mb-3 flex overflow-hidden rounded-xl bg-white p-4"
@click="handleToDetail(item)"
>
<image
v-if="item.cover"
class="mr-3 h-[140rpx] w-[180rpx] shrink-0 rounded-lg"
:src="item.cover"
mode="aspectFill"
/>
<view class="min-w-0 flex-1">
<view class="text-[28rpx] font-bold leading-snug text-[#333] line-clamp-2">
{{ item.title }}
</view>
<view v-if="item.summary" class="mt-1 text-[24rpx] leading-relaxed text-[#888] line-clamp-2">
{{ item.summary }}
</view>
<view class="mt-2 flex items-center gap-2">
<view
v-if="item.typeDisplayName"
class="rounded px-1.5 py-0.5 text-[20rpx]"
:style="{
color: item.typeColor || '#f83856',
backgroundColor: item.typeColor ? `${item.typeColor}1a` : '#fdeef1',
}"
>
{{ item.typeDisplayName }}
</view>
<text class="text-[22rpx] text-[#bbb]">
{{ formatDate(item.publishTime) }}
</text>
</view>
</view>
</view>
<view v-if="loading" class="py-6 text-center text-[24rpx] text-[#bbb]">
加载中...
</view>
<view v-else-if="allItems.length >= total.value" class="py-6 text-center text-[24rpx] text-[#bbb]">
没有更多了
</view>
</view>
<!-- 空态 -->
<view v-else class="flex flex-col items-center justify-center py-32">
<text class="text-[40rpx]">📢</text>
<text class="mt-4 text-[26rpx] text-[#999]">
{{ loading ? '加载中...' : '暂无公告' }}
</text>
</view>
</view>
</template>
<style scoped lang="scss">
.type-bar {
white-space: nowrap;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
</style>
+33 -48
View File
@@ -4,7 +4,7 @@
* 功能:关键词搜索文章/瞬间,结果列表展示
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getPostListByKeyword } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
@@ -43,23 +43,15 @@ const dataList = ref<{
updateTimestamp?: string
}[]>([])
/* ---------------- 动画(对应旧版 mixin calcAniWait/fnResetSetAniWaitIndex) ---------------- */
const aniWaitIndex = ref(0)
function resetAniWaitIndex() {
aniWaitIndex.value = 0
}
/** 计算列表项动画等待(每 10 项重置一轮,每项递增 50ms) */
function calcAniWait(index: number): number {
if ((index + 1) % 10 === 0) {
aniWaitIndex.value = 1
}
else {
aniWaitIndex.value += 1
}
return aniWaitIndex.value * 50
}
/* ---------------- 动画(对应旧版 mixin calcAniWait) ---------------- */
/** 预计算列表项入场延迟(每 10 项重置一轮,每项递增 50ms);必须在渲染外算好,渲染中修改响应式状态会导致递归更新 */
const calcAniDelays = computed(() => {
let wait = 0
return dataList.value.map((_, index) => {
wait = (index + 1) % 10 === 0 ? 1 : wait + 1
return wait * 50
})
})
/* ---------------- 搜索 ---------------- */
async function handleGetData() {
@@ -83,7 +75,6 @@ async function handleGetData() {
}
function handleOnSearch() {
resetAniWaitIndex()
if (!queryParams.value.keyword) {
dataList.value = []
loading.value = 'success'
@@ -131,7 +122,6 @@ function handleToTopPage(duration = 500) {
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
resetAniWaitIndex()
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
@@ -146,10 +136,6 @@ onLoad(async () => {
}
})
onShow(() => {
resetAniWaitIndex()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
@@ -160,7 +146,7 @@ onPullDownRefresh(() => {
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafd;">
<view class="app-page min-h-screen w-screen flex flex-col bg-page pb-6">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
@@ -168,36 +154,36 @@ onPullDownRefresh(() => {
@on-refresh="handleOnSearch"
/>
<template v-else>
<!-- 顶部搜索框 -->
<view class="search-bar fixed inset-x-0 top-0 z-6 bg-white px-3 py-2 shadow-sm">
<view class="search-input h-[68rpx] flex items-center gap-3 rounded-[34rpx] bg-[#f5f5f5] px-6">
<wd-icon name="search" size="16px" color="#999" />
<!-- 顶部搜索框(玻璃吸顶,呼应首页搜索条) -->
<view class="search-bar uh-global-card-glass sticky top-0 z-10 px-3 py-2">
<view class="search-input h-[72rpx] flex items-center gap-3 rounded-full bg-[#f6f3ee] px-5">
<wd-icon name="search" size="16px" color="#a8a294" />
<input
v-model="queryParams.keyword"
class="search-field flex-1 text-[26rpx]"
placeholder="搜索内容..."
class="search-field flex-1 text-[26rpx] text-gray-900"
placeholder="哈喽,想看些什么呢~"
placeholder-class="text-gray-400"
confirm-type="search"
@input="handleOnInput"
@confirm="handleOnSearch"
>
<view v-if="queryParams.keyword" class="clear-btn flex items-center" @click="queryParams.keyword = ''; handleOnSearch()">
<wd-icon name="close" size="14px" color="#999" />
<wd-icon name="close" size="14px" color="#a8a294" />
</view>
</view>
</view>
<view class="h-[100rpx] w-screen" />
<!-- 骨架屏 -->
<view v-if="loading === 'loading'" class="loading-wrap p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<view v-else-if="loading === 'error'" class="h-[60vh] flex items-center justify-center content-empty">
<view v-else-if="loading === 'error'" class="min-h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="搜索异常" />
</view>
<!-- 内容区域 -->
<view v-else class="content pt-6">
<view v-if="dataList.length === 0" class="h-[60vh] flex items-center justify-center content-empty">
<view v-else class="content pt-4">
<view v-if="dataList.length === 0" class="min-h-[60vh] flex items-center justify-center content-empty">
<wd-empty v-if="!queryParams.keyword" description="请输入关键词搜索" />
<wd-empty v-else :description="`未搜到 ${queryParams.keyword} 相关内容`" />
</view>
@@ -206,15 +192,18 @@ onPullDownRefresh(() => {
<view
v-for="(item, index) in dataList"
:key="index"
class="article-card fade-up mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm"
:style="{ animationDelay: `${calcAniWait(index)}ms` }"
class="article-card fade-up uh-global-card-glass mx-4 mb-4 flex flex-col overflow-hidden rounded-2xl p-4"
:style="{ animationDelay: `${calcAniDelays[index]}ms` }"
@click="handleToDetail(item)"
>
<view class="card-head mb-3 flex items-center">
<view class="type-tag mr-3 shrink-0 rounded-md px-1.5 py-0.5 text-[22rpx] text-white" :class="isArticle(item) ? 'bg-[#2196f3]' : 'bg-[#4caf50]'">
<view
class="type-tag mr-3 shrink-0 rounded-md px-1.5 py-0.5 text-[20rpx] leading-none"
:class="isArticle(item) ? 'bg-secondary text-[#4d7c0f]' : 'bg-[#e8e3d8] text-gray-600'"
>
{{ isArticle(item) ? '文章' : '瞬间' }}
</view>
<text class="card-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[28rpx] text-[#333] font-bold">{{ item.title }}</text>
<text class="card-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[28rpx] text-gray-900 font-bold">{{ item.title }}</text>
</view>
<mp-html
class="evan-markdown"
@@ -232,12 +221,12 @@ onPullDownRefresh(() => {
copy-by-long-press
/>
<view class="card-foot mt-3 flex items-center">
<text class="text-[24rpx] text-[#888]">{{ item.updateTimestamp ? `最近更新:${formatTimeUtil({ d: item.updateTimestamp, f: 'yyyy年MM月dd日 HH点mm分ss秒' })}` : '' }}</text>
<text class="text-[24rpx] text-gray-400">{{ item.updateTimestamp ? `最近更新:${formatTimeUtil({ d: item.updateTimestamp, f: 'yyyy年MM月dd日 HH点mm分ss秒' })}` : '' }}</text>
</view>
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
</view>
</block>
</view>
@@ -246,11 +235,7 @@ onPullDownRefresh(() => {
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
/* 列表项入场动画(对应旧版 tm-translate fadeUp) */
/* 列表项入场动画(对应旧版 tm-translate fadeUp):@keyframes 无法用原子类表达,保留 scoped 样式 */
.fade-up {
animation: fade-up 0.4s ease-out both;
+93 -85
View File
@@ -3,6 +3,7 @@
* 偏好设置页(两层:站点默认 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'
@@ -204,92 +205,107 @@ function handleResetAll() {
</script>
<template>
<view class="app-page box-border min-h-screen pb-[180rpx]" style="background-color: #fafafd;">
<view class="app-page box-border min-h-screen bg-page pb-[200rpx]">
<!-- 说明 -->
<view class="pref-tip mx-6 mt-6 rounded-xl bg-white px-6 py-4 shadow-sm">
<text class="tip-text text-[22rpx] leading-[1.6] text-[#909399]">
<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>
<!-- 布局设置 -->
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
<text class="title-text text-[30rpx] text-[#303133] font-bold">布局</text>
<text class="title-desc ml-3 text-[22rpx] text-[#999]">应用以及文章列表布局设置</text>
</view>
<view class="sheet-content">
<view
v-for="def in layoutPrefs"
:key="def.key"
class="pick-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7"
@click="handleOpenEnum(def)"
>
<view class="row-left flex flex-col">
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
<text v-if="isFollowing(def)" class="row-sub mt-1 text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
<text v-else class="row-sub mt-1 text-[20rpx] text-[#03a9f4]">已自定义</text>
</view>
<view class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-[#999]">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
<wd-icon name="arrow-right" size="12px" color="#999" />
<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>
<!-- 功能设置 -->
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
<text class="title-text text-[30rpx] text-[#303133] font-bold">功能</text>
<text class="title-desc ml-3 text-[22rpx] text-[#999]">一些常用的功能性设置</text>
</view>
<view class="sheet-content">
<template v-for="def in featurePrefs" :key="def.key">
<!-- 布尔开关 -->
<view v-if="def.kind === 'bool'" class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-5">
<view class="row-left flex flex-col">
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
<view class="mt-1 flex items-center gap-2">
<text v-if="isFollowing(def)" class="row-sub text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
<text v-else class="row-sub text-[20rpx] text-[#03a9f4]">已自定义</text>
<text
v-if="!isFollowing(def)"
class="revert-text text-[20rpx] text-[#909399] underline"
@click.stop="handleRevert(def.path)"
>跟随默认</text>
</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-8 py-5" @click="handleOpenEnum(def)">
<view class="row-left flex flex-col">
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
<view class="mt-1 flex items-center gap-2">
<text v-if="isFollowing(def)" class="row-sub text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
<text v-else class="row-sub text-[20rpx] text-[#03a9f4]">已自定义</text>
<text
v-if="!isFollowing(def)"
class="revert-text text-[20rpx] text-[#909399] underline"
@click.stop="handleRevert(def.path)"
>跟随默认</text>
</view>
</view>
<view class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-[#999]">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
<wd-icon name="arrow-right" size="12px" color="#999" />
<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>
</template>
</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 fixed bottom-0 left-0 box-border w-screen bg-white p-6 shadow-sm">
<wd-button type="danger" size="medium" block @click="handleResetAll">
重置为站点默认
</wd-button>
<!-- 底部操作栏(玻璃悬浮 + 底部安全区适配) -->
<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>
<!-- 枚举选择底部弹层 -->
@@ -301,22 +317,22 @@ function handleResetAll() {
@close="handleCloseEnum"
>
<view v-if="enumSheet.def" class="enum-sheet box-border w-full pb-[env(safe-area-inset-bottom)]">
<view class="enum-title border-b border-[#f5f5f5] py-6 text-center text-[30rpx] font-bold text-[#303133]">
<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-8 py-6"
:class="isFollowing(enumSheet.def) ? 'text-[#03a9f4]' : 'text-[#333]'"
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="#03a9f4" />
<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-[#f5f5f5] px-8 py-6"
:class="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def) ? 'text-[#03a9f4]' : 'text-[#333]'"
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>
@@ -324,18 +340,10 @@ function handleResetAll() {
v-if="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def)"
name="check"
size="16px"
color="#03a9f4"
color="#4d7c0f"
/>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.btn-bar {
:deep(wd-button) {
flex: 1;
}
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { DataLoadingStatusEnum } from '@/hooks/useDataLoadingStatus'
definePage({
style: {
navigationBarTitleText: '测试页面',
},
})
</script>
<template>
<view class="w-full h-screen">
<uh-data-loading :loading-status="DataLoadingStatusEnum.Empty"></uh-data-loading>
</view>
</template>
+5 -11
View File
@@ -117,7 +117,7 @@ onReachBottom(() => {
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
@@ -129,7 +129,7 @@ onReachBottom(() => {
<wd-skeleton :row="3" :animated="true" />
</view>
<view v-else class="content flex flex-col gap-6 p-3">
<view v-else class="content flex flex-col gap-4 p-3">
<view v-if="dataList.length === 0" class="min-h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="博主还未发布投票~" />
</view>
@@ -140,20 +140,14 @@ onReachBottom(() => {
:vote-name="vote.name"
@on-vote-success="handleOnVoteSuccess"
/>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
<view class="load-text py-5 text-center text-[24rpx] text-gray-400">
{{ loadMoreText }}
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>