mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-12 16:40:40 +08:00
feat: 新增收藏功能与多项体验优化
- 新增本地收藏store与工具函数,支持文章/瞬间收藏持久化 - 重构插件可用性检查hook,统一管理依赖插件状态 - 替换旧数据加载hook为新的状态管理方案 - 优化首页布局与加载逻辑,调整公告组件样式 - 为瞬间详情页添加点赞、评论与收藏功能 - 新增文本处理工具,支持HTML/Markdown转纯文本与摘要截断 - 修复评论弹窗适配瞬间类型的参数问题
This commit is contained in:
@@ -16,7 +16,7 @@ license: Complete terms in LICENSE.txt
|
||||
- **定义或修改 API**(约定使用 alova,格式、`meta` 传参、类型文件位置)
|
||||
- **定义或修改类型**(`src/api/types/` 下的接口与请求/响应类型)
|
||||
- **使用通用配置**(`src/config/` 的 appConfig / appSettings / haloGlobal / markdown)
|
||||
- **页面数据请求**(`useDataLoading` / `useRequest` hooks + `uh-data-loading` 组件)
|
||||
- **页面数据请求**(`useDataLoadingStatus` + `updateLoadingStatus` 管理四态 + `uh-data-loading` 组件)
|
||||
- **处理多平台差异**(H5 / 微信小程序 / APP,条件编译)
|
||||
|
||||
## 三条铁律(先记住)
|
||||
@@ -295,81 +295,128 @@ onLoad(() => {
|
||||
- 页面根节点用 `app-page` 类 + 主题底色:`<view class="app-page min-h-screen w-screen flex flex-col bg-page">`
|
||||
- 页面标题 `navigationBarTitleText` 写中文;下拉刷新 `enablePullDownRefresh: true`
|
||||
|
||||
### 5.4 页面数据请求(统一 hooks + uh-data-loading 组件)
|
||||
### 5.4 页面数据请求(统一 updateLoadingStatus + uh-data-loading 组件)
|
||||
|
||||
**数据加载四态**:`loading / error / empty / success`,由 `useDataLoading` hook 接管,
|
||||
页面只提供请求函数,**取代「手工 ref + try/catch 逐个搬运状态」的写法**。
|
||||
**数据加载四态**:`loading / error / empty / success`,统一用
|
||||
`useDataLoadingStatus`(`src/hooks/useDataLoadingStatus.ts`)的
|
||||
`updateLoadingStatus(DataLoadingStatusEnum.Xxx)` 管理,**取代「手工 ref + try/catch 逐个搬运状态」的写法**。
|
||||
首页、图库、瞬间、分类等 tabbar 页面均为此写法(参考 `src/pages/tabbar/category/category.vue`),
|
||||
**后续所有页面请求状态一律照此管理**。
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getMomentByName } from '@/api/halo'
|
||||
import { useDataLoading } from '@/hooks/useDataLoading'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getCategoryList } from '@/api/halo'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ICategory } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间详情',
|
||||
navigationBarTitleText: '分类',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundColor: '#f6f3ee', // 下拉露出的窗口底色对齐页面底色
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const { data: moment, status, run: loadMoment } = useDataLoading(
|
||||
async (): Promise<IMoment> => {
|
||||
const res = await getMomentByName(queryName.value)
|
||||
return res.data
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// 数据就绪后的处理
|
||||
},
|
||||
onError: () => {
|
||||
// 失败处理(run 已捕获异常,不会向外抛出)
|
||||
},
|
||||
},
|
||||
)
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const queryParams = ref({ size: 20, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
const dataList = ref<ICategory[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
|
||||
onLoad(() => {
|
||||
loadMoment()
|
||||
})
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading) // 请求开始
|
||||
if (!isLoadMore.value) {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await loadMoment()
|
||||
try {
|
||||
const res = await getCategoryList({ ...queryParams.value })
|
||||
hasNext.value = res.data.hasNext
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(res.data.items)
|
||||
: res.data.items
|
||||
// 请求结束:按数据是否为空区分 success / empty
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error) // 失败
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetInit() {
|
||||
dataList.value = []
|
||||
queryParams.value.page = 1
|
||||
hasNext.value = false
|
||||
isLoadMore.value = false
|
||||
loadMoreText.value = t('common.loading')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page box-border min-h-screen w-screen bg-page px-4 pb-8 pt-4">
|
||||
<view class="box-border min-h-screen w-screen flex flex-col bg-page p-3">
|
||||
<!-- 状态区:loading/error/empty 由 uh-data-loading 展示,refresh 触发重新请求 -->
|
||||
<uh-data-loading
|
||||
v-if="status !== 'success'"
|
||||
:loading-status="status"
|
||||
min-height="60vh"
|
||||
error-text="瞬间内容加载失败"
|
||||
empty-text="瞬间不存在或已被删除"
|
||||
@refresh="loadMoment"
|
||||
/>
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus" />
|
||||
|
||||
<!-- 成功态:渲染数据 -->
|
||||
<template v-else>
|
||||
<view>{{ moment?.spec.releaseTime }}</view>
|
||||
</template>
|
||||
<block v-else>
|
||||
<view v-for="item in dataList" :key="item.metadata.name" class="uh-global-card-glass rounded-xl p-4">
|
||||
{{ item.spec.displayName }}
|
||||
</view>
|
||||
<view class="w-full py-5 text-center text-xs text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
**要点**:
|
||||
|
||||
- `useDataLoading(fetcher, { isEmpty, onSuccess, onError })` 返回 `{ data, status, run }`
|
||||
- `isEmpty` 自定义判空(默认:数组看长度、对象看键数、空值视为空)
|
||||
- `run` 已捕获异常,失败置 `status='error'`,不会向外抛出
|
||||
- 模板里 `v-if="status !== 'success'"` 显示 `<uh-data-loading>`,否则渲染数据
|
||||
- `useDataLoadingStatus()` 返回 `{ loadingStatus, updateLoadingStatus }`;`DataLoadingStatusEnum` 四态:
|
||||
`Loading / Error / Empty / Success`
|
||||
- 请求开始置 `DataLoadingStatusEnum.Loading`;成功按数据是否为空置 `Success` / `Empty`;失败置 `Error`
|
||||
- 模板里 `v-if="loadingStatus !== DataLoadingStatusEnum.Success"` 显示 `<uh-data-loading>`,否则渲染数据
|
||||
- `<uh-data-loading>` 常用 props:`loading-status`(必传)、`min-height`、`loading-text`、
|
||||
`error-text`、`empty-text`(留空显示默认文案);`@refresh` 绑重试函数
|
||||
- **旧页面**用的 `useDataLoadingStatus`(`DataLoadingStatusEnum`)已标记 `@deprecated`,
|
||||
新代码一律用 `useDataLoading`
|
||||
- 首页特例:入口拦截 + 文章列表空时用 `v-if="loadingStatus !== Success && articleList.length === 0"`,
|
||||
避免轮播/公告区被占位组件顶掉
|
||||
- 详情页等单数据场景(如 `moment-detail`)可用 `useDataLoading` 状态机(返回 `{ data, status, run }`),
|
||||
列表页/四态展示优先 `updateLoadingStatus` 写法
|
||||
- 简单场景也可用 `useRequest(fn, { immediate })`:返回 `{ loading, error, data, run }`
|
||||
|
||||
### 5.5 列表页(分页加载)
|
||||
@@ -640,7 +687,7 @@ loadMoreText.value = t('common.loadMore')
|
||||
|
||||
- 新 hooks 放 `src/hooks/`,auto-import(`unplugin-auto-import` 已配 `dirs: ['src/hooks']`),页面**免 import 直接调用**
|
||||
- 命名 `useXxx`;有配套单测的写 `xxx.test.ts`(如 `useDataLoading.test.ts`、`useRequest.test.ts`)
|
||||
- 参考既有实现风格:`useDataLoading`(状态机四态)、`useScroll`、`useUpload`(平台条件编译处理)
|
||||
- 参考既有实现风格:`useDataLoadingStatus`(页面四态管理,见 §5.4)、`useDataLoading`(详情页单数据状态机)、`useScroll`、`useUpload`(平台条件编译处理)
|
||||
|
||||
### 8.6 Git 提交与合入
|
||||
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
isComment ?: boolean
|
||||
title ?: string
|
||||
postName : string
|
||||
/** 评论目标 kind(文章 Post / 瞬间 Moment) */
|
||||
subjectKind ?: string
|
||||
}>(), {
|
||||
isComment: false,
|
||||
title: '',
|
||||
subjectKind: 'Post',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -189,7 +192,7 @@
|
||||
},
|
||||
subjectRef: {
|
||||
group: 'content.halo.run',
|
||||
kind: 'Post',
|
||||
kind: props.subjectKind,
|
||||
name: form.value.postName,
|
||||
version: 'v1alpha1',
|
||||
},
|
||||
|
||||
@@ -18,8 +18,8 @@ interface IProps {
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
loadingStatus: 'loading',
|
||||
minHeight: '60vh',
|
||||
loadingText: '稍等,正在加载中哦...',
|
||||
minHeight: '75vh',
|
||||
loadingText: '稍等,正在加载中哦',
|
||||
errorText: '哎呀,加载失败了呢~',
|
||||
emptyText: '啊偶,暂时没有数据呢~',
|
||||
loadingSubText: '',
|
||||
@@ -63,7 +63,7 @@ const statusScene = computed(() => {
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="w-full flex flex-col items-center justify-center gap-y-8 text-sm"
|
||||
class="w-full flex flex-col items-center justify-center gap-y-4 text-sm"
|
||||
:style="{ minHeight: props.minHeight }"
|
||||
>
|
||||
<!-- 状态舞台:光晕 + 漂浮装饰点 + 毛玻璃表情珠 -->
|
||||
@@ -83,8 +83,8 @@ const statusScene = computed(() => {
|
||||
<view class="flex items-center justify-center text-[28rpx] font-bold" :class="statusScene.mainTextClass">
|
||||
<text>{{ statusScene.mainText }}</text>
|
||||
<!-- 加载中三点跳动 -->
|
||||
<view v-if="isLoading" class="ml-3 flex items-end gap-1">
|
||||
<view v-for="n in 3" :key="n" class="typing-dot" />
|
||||
<view v-if="isLoading" class="ml-1 flex items-end gap-1">
|
||||
<view v-for="n in 3" :key="n" class="typing-dot bg-primary" />
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="statusScene.subText" class="mt-3 text-[24rpx] text-gray-400">
|
||||
@@ -98,9 +98,6 @@ const statusScene = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 氛围化状态占位:keyframes/状态配色无法用原子类表达,保留 scoped 样式 */
|
||||
|
||||
/* —— 毛玻璃表情珠(三态共用,缓慢上下漂浮) —— */
|
||||
.bubble {
|
||||
animation: bubble-float 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -109,7 +106,6 @@ const statusScene = computed(() => {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* —— 光晕(状态色,缓慢呼吸) —— */
|
||||
.glow {
|
||||
animation: glow-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@@ -126,7 +122,6 @@ const statusScene = computed(() => {
|
||||
background: rgba(217, 249, 157, 0.5);
|
||||
}
|
||||
|
||||
/* —— 漂浮装饰点(品牌双色,错峰漂浮) —— */
|
||||
.deco-dot {
|
||||
animation: deco-float 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -168,7 +163,6 @@ const statusScene = computed(() => {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(163, 230, 53, 0.95);
|
||||
animation: dot-jump 1s ease-in-out infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 轮播组件(源自旧项目 components/e-swiper,新建复刻)
|
||||
* 数据高内聚:默认内部请求 plugin-uni-halo 公开 banners 接口(getBanners),支持外部 list 覆盖
|
||||
* 支持:图片轮播、日期角标(useTop,显示当前条目 date 快照)、标题浮层(useTitle)、
|
||||
* 作者/日期信息浮层(useUser)、底部小图指示器(useDot)
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { getBanners } from '@/api/uni-halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import type { IBannerPublicItem } from '@/api/types/uni-halo'
|
||||
|
||||
export interface IBannerItem {
|
||||
/** 条目标识(Banner 为 metadata.name;兼容旧数据) */
|
||||
id ?: string | number
|
||||
/** Banner 条目 metadata.name(custom 详情页跳转用) */
|
||||
name ?: string
|
||||
title ?: string
|
||||
image ?: string
|
||||
src ?: string
|
||||
/** 来源:post=文章快照 / custom=自定义 */
|
||||
type ?: string
|
||||
/** 文章 id(source=post 时跳转文章详情) */
|
||||
postId ?: string
|
||||
content ?: string
|
||||
url ?: string
|
||||
/** 展示日期(ISO 快照) */
|
||||
date ?: string
|
||||
authorName ?: string
|
||||
authorAvatar ?: string
|
||||
[key : string] : unknown
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title ?: string
|
||||
height ?: string
|
||||
dotPosition ?: string
|
||||
/** 日期角标(显示当前条目 date) */
|
||||
useTop ?: boolean
|
||||
/** 底部小图指示器 */
|
||||
useDot ?: boolean
|
||||
/** 标题浮层 */
|
||||
useTitle ?: boolean
|
||||
/** 作者/日期信息浮层 */
|
||||
useUser ?: boolean
|
||||
/** 轮播数据列表(可选;不传时组件内部调公开 banners 接口拉取) */
|
||||
list ?: IBannerItem[]
|
||||
/** 当前选中的项(指示器坐标位置) */
|
||||
current ?: number
|
||||
/** 是否自动轮播 */
|
||||
autoplay ?: boolean
|
||||
}>(), {
|
||||
title: '',
|
||||
height: '450rpx',
|
||||
dotPosition: 'bottom',
|
||||
useTop: true,
|
||||
useDot: true,
|
||||
useTitle: true,
|
||||
useUser: true,
|
||||
current: 0,
|
||||
autoplay: false,
|
||||
})
|
||||
const appConfigStore = useAppConfigStore()
|
||||
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const bannerConfig = computed(() => haloConfigs.value.pageConfig?.homeConfig?.bannerConfig)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const currentIndex = ref(props.current)
|
||||
/** 是否禁止用户 touch 操作 */
|
||||
const disableTouch = ref(false)
|
||||
|
||||
/* ---------------- 数据(高内聚:内部请求公开接口) ---------------- */
|
||||
const internalList = ref<IBannerItem[]>([])
|
||||
|
||||
/** 展示列表:外部传入(list)优先,否则使用内部拉取数据 */
|
||||
const displayItems = computed<IBannerItem[]>(() =>
|
||||
props.list && props.list.length > 0 ? props.list : internalList.value,
|
||||
)
|
||||
|
||||
/** 公开 Banner 条目 → 轮播展示项 */
|
||||
function mapBanners(items : IBannerPublicItem[]) : IBannerItem[] {
|
||||
return items.map(item => ({
|
||||
id: item.name,
|
||||
name: item.name,
|
||||
title: item.title || '',
|
||||
image: checkThumbnailUrl(item.cover),
|
||||
src: checkThumbnailUrl(item.cover),
|
||||
type: item.source,
|
||||
postId: item.postId,
|
||||
url: item.link,
|
||||
date: item.date,
|
||||
authorName: item.authorName,
|
||||
authorAvatar: item.authorAvatar ? checkAvatarUrl(item.authorAvatar) : '',
|
||||
}))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 外部已传数据时不再重复请求
|
||||
if (props.list && props.list.length > 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getBanners()
|
||||
internalList.value = mapBanners(res.data || [])
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取轮播图失败', err)
|
||||
}
|
||||
})
|
||||
|
||||
// 列表变化(外部覆盖/接口返回)后索引越界时归零
|
||||
watch(displayItems, (val) => {
|
||||
if (currentIndex.value >= val.length) {
|
||||
currentIndex.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
/* ---------------- 计算属性 ---------------- */
|
||||
const currentItem = computed<IBannerItem>(() =>
|
||||
displayItems.value[currentIndex.value] || {},
|
||||
)
|
||||
|
||||
/** 日期角标(useTop):当前条目 date 快照转换(年/月/日) */
|
||||
const dateParts = computed(() => {
|
||||
const dateStr = currentItem.value.date as string | undefined
|
||||
if (!dateStr) {
|
||||
return null
|
||||
}
|
||||
const d = new Date(dateStr)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return null
|
||||
}
|
||||
const monthArray = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
||||
return {
|
||||
day: String(d.getDate()).padStart(2, '0'),
|
||||
month: String(d.getMonth() + 1).padStart(2, '0'),
|
||||
monthEn: monthArray[d.getMonth()],
|
||||
year: String(d.getFullYear()),
|
||||
}
|
||||
})
|
||||
|
||||
const currentTitle = computed(() => currentItem.value.title || props.title || '')
|
||||
|
||||
/** 作者日期展示(useUser 用) */
|
||||
const authorDateText = computed(() => {
|
||||
const dateStr = currentItem.value.date as string | undefined
|
||||
if (!dateStr) {
|
||||
return ''
|
||||
}
|
||||
const d = new Date(dateStr)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const pad = (n : number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
})
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
/** current 改变时会触发 change 事件,event.detail = {current, source} */
|
||||
function change(e : { detail : { current : number, source : string } }) {
|
||||
const { current, source } = e.detail
|
||||
// 只有页面自动切换、手动切换时才轮播,其他不允许
|
||||
if (source === 'autoplay' || source === 'touch') {
|
||||
const event = { current }
|
||||
currentIndex.value = current
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动点击了指示器[小图模式] */
|
||||
function swiperIndTap(index : number) {
|
||||
const event = { current: index }
|
||||
currentIndex.value = index
|
||||
}
|
||||
|
||||
function handleOnClick(item : IBannerItem) {
|
||||
// 审核模式下照常展示 Banner,点击分发不拦截(详情页自行处理审核限制)
|
||||
if (item.type === 'custom') {
|
||||
// 自定义条目:跳转 Banner 详情页,页面内调公开详情接口展示 content/外链
|
||||
if (item.name) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/banner-detail/banner-detail?name=${item.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// 文章来源
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/article-detail/article-detail?name=${item.postId}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-if="displayItems.length > 0" class="relative w-full px-4 pt-12 mb-14 box-border">
|
||||
|
||||
<view class="absolute inset-0 blur-[2rpx]">
|
||||
<image :src="displayItems[currentIndex].src" class="h-full w-full" mode="aspectFill"></image>
|
||||
</view>
|
||||
|
||||
<view class="uh-global-card-glass box-border relative w-full overflow-hidden rounded-xl translate-y-12" :class="[dotPosition]">
|
||||
<swiper class="w-full" :style="{ height }" :circular="true" :indicator-dots="false"
|
||||
:autoplay="autoplay" :interval="3000" :duration="1000" :current="currentIndex"
|
||||
:disable-touch="disableTouch" @change="change">
|
||||
<swiper-item v-for="(item, index) in displayItems" :key="index">
|
||||
<image :src="item.image || item.src" class="h-full w-full" mode="aspectFill"
|
||||
@click.stop="handleOnClick(item)" />
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 指示器 [Top 日期角标]:显示当前条目 date(年/月/日) -->
|
||||
<view v-if="useTop && dateParts"
|
||||
class="box-border absolute inset-x-0 top-0 z-5 flex items-center px-[24rpx] py-[16rpx]">
|
||||
<text class="text-[40rpx] text-white font-bold text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
|
||||
{{ dateParts.day }}
|
||||
</text>
|
||||
<view class="ml-[12rpx] h-[40rpx] w-[2rpx] bg-white/50" />
|
||||
<view class="ml-[12rpx] flex flex-col">
|
||||
<text class="text-[20rpx] text-white text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
|
||||
{{ dateParts.monthEn }}
|
||||
</text>
|
||||
<text class="text-[16rpx] text-white/80">{{ dateParts.year }}</text>
|
||||
</view>
|
||||
<text
|
||||
class="text-overflow-2 ml-[20rpx] block flex-1 text-[24rpx] text-white text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
|
||||
{{ title }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 指示器 标题区域 + 作者/日期信息(useUser) -->
|
||||
<view v-if="useTitle"
|
||||
class="absolute inset-x-0 bottom-0 z-5 from-black/45 to-transparent bg-gradient-to-t px-[24rpx] pb-[20rpx] pt-[48rpx]">
|
||||
<view v-if="useUser && (currentItem.authorName || authorDateText)"
|
||||
class="mb-[8rpx] flex items-center gap-[8rpx]">
|
||||
<view v-if="currentItem.authorAvatar"
|
||||
class="h-[36rpx] w-[36rpx] overflow-hidden border-[1rpx] border-white/60 rounded-full">
|
||||
<image :src="currentItem.authorAvatar" class="h-full w-full" mode="aspectFill" />
|
||||
</view>
|
||||
<text class="text-[22rpx] text-white/92 text-shadow-[0_1rpx_4rpx_rgba(0,0,0,0.4)]">
|
||||
{{ currentItem.authorName }}
|
||||
</text>
|
||||
<text v-if="authorDateText"
|
||||
class="text-[20rpx] text-white/70 text-shadow-[0_1rpx_4rpx_rgba(0,0,0,0.4)]">
|
||||
{{ authorDateText }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="currentTitle"
|
||||
class="text-overflow-2 block text-[28rpx] text-white font-bold text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
|
||||
{{ currentTitle }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 指示器 [底部小图列表] -->
|
||||
<view v-if="useDot" class="absolute inset-x-0 bottom-3 z-5 flex justify-end px-[24rpx]">
|
||||
<view class="flex gap-1">
|
||||
<view v-for="(item, index) in displayItems" :key="index"
|
||||
class="uh-global-card-glass h-[64rpx] w-[96rpx] overflow-hidden border-[2rpx] rounded-[8rpx]"
|
||||
:class="currentIndex === index ? 'opacity-100 border-white' : 'opacity-60 border-transparent'"
|
||||
@click="swiperIndTap(index)">
|
||||
<image :src="item.image || item.src" class="h-full w-full" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -1,11 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 首页公告滚动条(plugin-uni-halo 通知公告,2026-09-03 客户端新增)
|
||||
* 展示最新/置顶公告(公开 GET /notices 服务端默认 priority desc + publishTime desc),
|
||||
* 取前 6 条标题垂直循环轮播:点击当前标题跳公告详情,右侧「更多」跳公告列表页。
|
||||
* 无公告(或加载失败)时整条不渲染,不占首页空间。
|
||||
* UIUX 见 .docs/notice-module-client-design.md
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getNotices } from '@/api/uni-halo'
|
||||
import type { INoticeListVo } from '@/api/types/uni-halo'
|
||||
@@ -47,14 +40,14 @@ onMounted(() => {
|
||||
<template>
|
||||
<view
|
||||
v-if="showList.length > 0"
|
||||
class="mx-3 mb-2 flex items-center rounded-xl bg-white px-3 py-1.5 shadow-sm"
|
||||
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-[28rpx]">
|
||||
<text class="text-sm">
|
||||
📢
|
||||
</text>
|
||||
<text class="text-[24rpx] font-bold text-[#f83856]">
|
||||
<text class="text-xs font-bold text-red-400">
|
||||
公告
|
||||
</text>
|
||||
</view>
|
||||
@@ -76,7 +69,7 @@ onMounted(() => {
|
||||
class="h-full w-full"
|
||||
>
|
||||
<view
|
||||
class="flex h-full w-full items-center truncate text-[24rpx] text-[#555]"
|
||||
class="flex h-full w-full items-center truncate text-xs text-gray-500"
|
||||
@click="handleTap(item)"
|
||||
>
|
||||
{{ item.title }}
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getNoticeLatest } from '@/api/uni-halo'
|
||||
import { getCache, setCache } from '@/utils/storage'
|
||||
import type { INoticeListVo } from '@/api/types/uni-halo'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getNoticeLatest } from '@/api/uni-halo'
|
||||
import { getCache, setCache } from '@/utils/storage'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
import type { INoticeListVo } from '@/api/types/uni-halo'
|
||||
|
||||
const HIDDEN_KEY_PREFIX = 'notice_latest_hidden'
|
||||
const HIDDEN_KEY_PREFIX = 'notice_latest_hidden'
|
||||
|
||||
const isShow = ref(false)
|
||||
const notice = ref<INoticeListVo | null>(null)
|
||||
const checking = ref(false)
|
||||
const isShow = ref(false)
|
||||
const notice = ref<INoticeListVo | null>(null)
|
||||
const checking = ref(false)
|
||||
|
||||
function todayKey(name: string): string {
|
||||
function todayKey(name : string) : string {
|
||||
const date = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const pad = (n : number) => String(n).padStart(2, '0')
|
||||
return `${HIDDEN_KEY_PREFIX}_${name}_${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
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')
|
||||
const pad = (n : number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckLatest() {
|
||||
if (checking.value)
|
||||
return
|
||||
async function handleCheckLatest() {
|
||||
if (checking.value) { return }
|
||||
checking.value = true
|
||||
try {
|
||||
const res = await getNoticeLatest()
|
||||
const latest = res.data
|
||||
if (!latest?.name || !latest.title)
|
||||
return
|
||||
if (!latest?.name || !latest.title) { return }
|
||||
// 今日已看过则不再打扰
|
||||
if (getCache<string>(todayKey(latest.name)))
|
||||
return
|
||||
if (getCache<string>(todayKey(latest.name))) { return }
|
||||
notice.value = latest
|
||||
isShow.value = true
|
||||
}
|
||||
@@ -47,44 +45,38 @@ async function handleCheckLatest() {
|
||||
finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅关闭(不记录,下次进入可再弹) */
|
||||
function handleClose() {
|
||||
/** 仅关闭(不记录,下次进入可再弹) */
|
||||
function handleClose() {
|
||||
isShow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 今日不再提醒:记录后关闭 */
|
||||
function handleDismissForever() {
|
||||
/** 今日不再提醒:记录后关闭 */
|
||||
function handleDismissForever() {
|
||||
if (notice.value?.name) {
|
||||
setCache(todayKey(notice.value.name), '1')
|
||||
}
|
||||
isShow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 查看全文:记录今日已看并跳公告详情页 */
|
||||
function handleViewAll() {
|
||||
/** 查看全文:记录今日已看并跳公告详情页 */
|
||||
function handleViewAll() {
|
||||
if (notice.value?.name) {
|
||||
setCache(todayKey(notice.value.name), '1')
|
||||
const detailName = notice.value.name
|
||||
isShow.value = false
|
||||
uni.navigateTo({ url: `/pages-blog/notice/detail?name=${detailName}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(() => {
|
||||
handleCheckLatest()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="isShow"
|
||||
position="center"
|
||||
custom-class="rounded-xl"
|
||||
z-index="9999"
|
||||
@close="handleClose"
|
||||
>
|
||||
<wd-popup v-model="isShow" position="center" custom-class="rounded-xl" :z-index="9999" @close="handleClose">
|
||||
<view v-if="notice" class="box-border w-[80vw] p-6">
|
||||
<!-- 头部:标题 + 关闭 -->
|
||||
<view class="flex items-center justify-between">
|
||||
@@ -95,31 +87,28 @@ onMounted(() => {
|
||||
<text class="text-md font-bold text-gray-900">
|
||||
最新公告
|
||||
</text>
|
||||
<view
|
||||
v-if="notice.typeDisplayName"
|
||||
class="rounded px-1.5 py-0.5 text-xs"
|
||||
:style="{
|
||||
<view v-if="notice.typeDisplayName" class="rounded px-1.5 py-0.5 text-xs" :style="{
|
||||
color: notice.typeColor || '#f83856',
|
||||
backgroundColor: notice.typeColor ? `${notice.typeColor}1a` : '#fdeef1',
|
||||
}"
|
||||
>
|
||||
}">
|
||||
{{ notice.typeDisplayName }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="flex h-8 w-8 items-center justify-center text-gray-500" @click="handleClose">
|
||||
<view class="flex h-8 w-8 items-center justify-end text-gray-500" @click="handleClose">
|
||||
<wd-icon name="close" size="16px" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容 -->
|
||||
<view class="mt-4">
|
||||
<view class="text-[32rpx] font-bold leading-snug text-[#222]">
|
||||
<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>
|
||||
<view v-if="notice.summary" class="mt-3 text-[26rpx] leading-relaxed text-gray-500">
|
||||
<view v-if="notice.summary" class="mt-3 text-sm leading-relaxed text-gray-500">
|
||||
{{ notice.summary }}
|
||||
</view>
|
||||
<view v-if="notice.publishTime" class="mt-3 text-[22rpx] text-gray-400">
|
||||
<view v-if="notice.publishTime" class="mt-3 text-xs text-gray-400">
|
||||
日期:{{ formatDate(notice.publishTime) }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
/**
|
||||
* 插件不可用提示(源自旧项目 components/plugin-unavailable,新建复刻)
|
||||
* 当依赖的 Halo 插件未安装/未启用时展示:插件 logo、名称、错误标签、描述、插件地址、复制/反馈按钮
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { NeedPlugins } from '@/utils/plugin'
|
||||
import { computed } from 'vue'
|
||||
import { NeedPlugins } from '@/hooks/usePluginAvailable'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 插件名称(与 NeedPlugins 中的 id 对应) */
|
||||
pluginId: string
|
||||
errorText?: string
|
||||
useDecoration?: boolean
|
||||
useBorder?: boolean
|
||||
customStyle?: Record<string, string>
|
||||
}>(), {
|
||||
pluginId : string
|
||||
errorText ?: string
|
||||
useDecoration ?: boolean
|
||||
useBorder ?: boolean
|
||||
customStyle ?: Record<string, string>
|
||||
}>(), {
|
||||
errorText: '',
|
||||
useDecoration: true,
|
||||
useBorder: true,
|
||||
customStyle: () => ({}),
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'on-refresh'): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e : 'on-refresh') : void
|
||||
}>()
|
||||
|
||||
/** 插件信息(未在清单中时兜底) */
|
||||
const pluginInfo = computed(() => {
|
||||
/** 插件信息(未在清单中时兜底) */
|
||||
const pluginInfo = computed(() => {
|
||||
const info = NeedPlugins.get(props.pluginId)
|
||||
return info || {
|
||||
id: props.pluginId,
|
||||
@@ -34,19 +34,19 @@ const pluginInfo = computed(() => {
|
||||
logo: '',
|
||||
url: '',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const defaultStyle = {
|
||||
const defaultStyle = {
|
||||
width: '80vw',
|
||||
borderRadius: '24rpx',
|
||||
}
|
||||
}
|
||||
|
||||
const calcCustomStyle = computed(() => ({
|
||||
const calcCustomStyle = computed(() => ({
|
||||
...defaultStyle,
|
||||
...props.customStyle,
|
||||
}))
|
||||
}))
|
||||
|
||||
function copy() {
|
||||
function copy() {
|
||||
if (!pluginInfo.value.url)
|
||||
return
|
||||
uni.setClipboardData({
|
||||
@@ -56,54 +56,33 @@ function copy() {
|
||||
uni.showToast({ icon: 'none', title: '插件地址已复制' })
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="pluginInfo"
|
||||
<view v-if="pluginInfo"
|
||||
class="uh-plugin-unavailable mx-auto my-auto box-border flex flex-col gap-6 p-10 text-[28rpx]"
|
||||
:class="{ border: useBorder, decoration: useDecoration }"
|
||||
:style="[calcCustomStyle]"
|
||||
>
|
||||
:class="{ border: useBorder, decoration: useDecoration }" :style="[calcCustomStyle]">
|
||||
<!-- 图标 -->
|
||||
<image class="plugin-logo box-border h-[120rpx] w-[120rpx] rounded-3xl" :src="pluginInfo.logo" mode="scaleToFill" />
|
||||
<image class="plugin-logo box-border h-[120rpx] w-[120rpx] rounded-3xl" :src="pluginInfo.logo"
|
||||
mode="scaleToFill" />
|
||||
<!-- 名称 -->
|
||||
<view class="plugin-name box-border text-[32rpx] text-[#333] font-bold">
|
||||
{{ pluginInfo.name }}
|
||||
</view>
|
||||
<!-- 错误标签 -->
|
||||
<view class="plugin-error box-border rounded-[36rpx] px-4 py-1.5 text-[24rpx] font-bold" style="background-color: rgb(255 61 49 / 7.5%); color: rgb(255 61 49);">
|
||||
未安装/启用插件
|
||||
</view>
|
||||
<!-- 描述 -->
|
||||
<view class="plugin-desc box-border w-[60vw] text-center text-[24rpx] text-[#64748b]">
|
||||
{{ pluginInfo.desc }}
|
||||
</view>
|
||||
|
||||
<!-- 自定义错误提示 -->
|
||||
<view v-if="errorText" class="plugin-tip box-border border-2 rounded-xl border-dashed px-5 py-2.5 text-[24rpx]" style="border-color: #f2c97d; color: #f0a020;">
|
||||
<view v-if="errorText" class="plugin-tip box-border border-2 rounded-xl border-dashed px-5 py-2.5 text-[24rpx]"
|
||||
style="border-color: #f2c97d; color: #f0a020;">
|
||||
{{ errorText }}
|
||||
</view>
|
||||
<!-- 插件地址 -->
|
||||
<view class="plugin-url box-border w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-xl bg-[#f1f5f9] px-6 py-4 text-[24rpx] text-[#666]">
|
||||
插件地址:{{ pluginInfo.url }}
|
||||
</view>
|
||||
|
||||
<!-- 反馈按钮/复制地址 -->
|
||||
<view class="plugin-btns box-border w-full">
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<wd-button type="primary" block size="medium" @click="copy">
|
||||
复制地址
|
||||
</wd-button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="flex gap-3">
|
||||
<wd-button type="primary" plain block size="medium" @click="copy">
|
||||
复制地址
|
||||
</wd-button>
|
||||
<wd-button type="warning" plain block size="medium" open-type="contact">
|
||||
提交反馈
|
||||
</wd-button>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<!-- 刷新按钮 -->
|
||||
@@ -120,7 +99,7 @@ function copy() {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.uh-plugin-unavailable {
|
||||
.uh-plugin-unavailable {
|
||||
&.border {
|
||||
border: 2rpx solid #eee;
|
||||
}
|
||||
@@ -131,5 +110,5 @@ function copy() {
|
||||
backdrop-filter: blur(6rpx);
|
||||
border-top: 12rpx solid rgb(3 169 244);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 插件不可用提示(源自旧项目 components/plugin-unavailable,新建复刻)
|
||||
* 当依赖的 Halo 插件未安装/未启用时展示:插件 logo、名称、错误标签、描述、插件地址、复制/反馈按钮
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { NeedPlugins } from '@/utils/plugin'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 插件名称(与 NeedPlugins 中的 id 对应) */
|
||||
pluginId: string
|
||||
errorText?: string
|
||||
useDecoration?: boolean
|
||||
useBorder?: boolean
|
||||
customStyle?: Record<string, string>
|
||||
}>(), {
|
||||
errorText: '',
|
||||
useDecoration: true,
|
||||
useBorder: true,
|
||||
customStyle: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'on-refresh'): void
|
||||
}>()
|
||||
|
||||
/** 插件信息(未在清单中时兜底) */
|
||||
const pluginInfo = computed(() => {
|
||||
const info = NeedPlugins.get(props.pluginId)
|
||||
return info || {
|
||||
id: props.pluginId,
|
||||
name: props.pluginId,
|
||||
desc: '',
|
||||
logo: '',
|
||||
url: '',
|
||||
}
|
||||
})
|
||||
|
||||
const defaultStyle = {
|
||||
width: '80vw',
|
||||
borderRadius: '24rpx',
|
||||
}
|
||||
|
||||
const calcCustomStyle = computed(() => ({
|
||||
...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>
|
||||
<view
|
||||
v-if="pluginInfo"
|
||||
class="uh-plugin-unavailable mx-auto my-auto box-border flex flex-col gap-6 p-10 text-[28rpx]"
|
||||
:class="{ border: useBorder, decoration: useDecoration }"
|
||||
:style="[calcCustomStyle]"
|
||||
>
|
||||
<!-- 图标 -->
|
||||
<image class="plugin-logo box-border h-[120rpx] w-[120rpx] rounded-3xl" :src="pluginInfo.logo" mode="scaleToFill" />
|
||||
<!-- 名称 -->
|
||||
<view class="plugin-name box-border text-[32rpx] text-[#333] font-bold">
|
||||
{{ pluginInfo.name }}
|
||||
</view>
|
||||
<!-- 错误标签 -->
|
||||
<view class="plugin-error box-border rounded-[36rpx] px-4 py-1.5 text-[24rpx] font-bold" style="background-color: rgb(255 61 49 / 7.5%); color: rgb(255 61 49);">
|
||||
未安装/启用插件
|
||||
</view>
|
||||
<!-- 描述 -->
|
||||
<view class="plugin-desc box-border w-[60vw] text-center text-[24rpx] text-[#64748b]">
|
||||
{{ pluginInfo.desc }}
|
||||
</view>
|
||||
<!-- 自定义错误提示 -->
|
||||
<view v-if="errorText" class="plugin-tip box-border border-2 rounded-xl border-dashed px-5 py-2.5 text-[24rpx]" style="border-color: #f2c97d; color: #f0a020;">
|
||||
{{ errorText }}
|
||||
</view>
|
||||
<!-- 插件地址 -->
|
||||
<view class="plugin-url box-border w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-xl bg-[#f1f5f9] px-6 py-4 text-[24rpx] text-[#666]">
|
||||
插件地址:{{ pluginInfo.url }}
|
||||
</view>
|
||||
<!-- 反馈按钮/复制地址 -->
|
||||
<view class="plugin-btns box-border w-full">
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<wd-button type="primary" block size="medium" @click="copy">
|
||||
复制地址
|
||||
</wd-button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="flex gap-3">
|
||||
<wd-button type="primary" plain block size="medium" @click="copy">
|
||||
复制地址
|
||||
</wd-button>
|
||||
<wd-button type="warning" plain block size="medium" open-type="contact">
|
||||
提交反馈
|
||||
</wd-button>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<!-- 刷新按钮 -->
|
||||
<view class="flex justify-center">
|
||||
<wd-button size="small" plain type="info" @click="emit('on-refresh')">
|
||||
刷新试试
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view class="plugin-copyright text-[20rpx] text-[#999]" style="transform: scale(0.9) translateY(20px);">
|
||||
提示:请确保 Halo 博客已安装相关插件
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.uh-plugin-unavailable {
|
||||
&.border {
|
||||
border: 2rpx solid #eee;
|
||||
}
|
||||
|
||||
&.decoration {
|
||||
background-color: rgb(255 255 255 / 95%);
|
||||
box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);
|
||||
backdrop-filter: blur(6rpx);
|
||||
border-top: 12rpx solid rgb(3 169 244);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,6 @@
|
||||
* 维护页按 reason 展示默认(未配置维护信息)或配置文案。设计见插件
|
||||
* .docs/maintenance-config-design.md §8。
|
||||
*/
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
|
||||
/** 维护拦截原因:plugin 主插件未激活 / maintenance 维护模式开启 */
|
||||
@@ -33,14 +32,16 @@ export const MAINTENANCE_PLUGIN_ID = 'plugin-uni-halo'
|
||||
*/
|
||||
export function useMaintenanceIntercept() {
|
||||
const appConfigStore = useAppConfigStore()
|
||||
/** 主插件可用性 hook(checkIntercept 内 await check 后读取 available) */
|
||||
const { available: pluginAvailable, check: checkPluginAvailable } = usePluginAvailable(MAINTENANCE_PLUGIN_ID)
|
||||
|
||||
/**
|
||||
* 检查是否命中拦截(插件可用性 + 维护模式)。
|
||||
* @param force 是否强制刷新配置(默认 false 走 bootstrap TTL 缓存)
|
||||
*/
|
||||
async function checkIntercept(force = false): Promise<IMaintenanceInterceptResult> {
|
||||
const pluginAvailable = await usePluginAvailable(MAINTENANCE_PLUGIN_ID)
|
||||
if (!pluginAvailable)
|
||||
await checkPluginAvailable()
|
||||
if (!pluginAvailable.value)
|
||||
return { intercepted: true, reason: 'plugin' }
|
||||
|
||||
const { ok } = await appConfigStore.bootstrap({ force })
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
/**
|
||||
* 插件清单与可用性检查(源自旧项目 utils/plugin.js,TS 化)
|
||||
* 插件清单与可用性 hook
|
||||
* 源自旧项目 utils/plugin.js(TS 化)与 utils/plugin.ts,整体迁移至 hooks 目录。
|
||||
* 提供:
|
||||
* - 依赖插件 ID 常量(NeedPluginIds)与插件清单(NeedPlugins)
|
||||
* - 底层可用性检查(checkNeedPluginAvailable)
|
||||
* - 响应式 hook(usePluginAvailable):available 状态 + check 校验函数,页面免 import 直接调用
|
||||
* (auto-import 已配置 src/hooks,无需手写 import)
|
||||
*
|
||||
* @example
|
||||
* const { available, check } = usePluginAvailable('plugin-vote')
|
||||
* onLoad(async () => {
|
||||
* await check()
|
||||
* if (!available.value) return
|
||||
* handleGetData()
|
||||
* })
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { checkPluginAvailable } from '@/api/halo'
|
||||
import { checkUrl } from '@/utils/url'
|
||||
|
||||
@@ -124,10 +139,26 @@ export async function checkNeedPluginAvailable(pluginId: string): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查插件可用性(供页面 onLoad 使用,源自 uh-plugin-unavailable 组件,移出避免 script setup export)
|
||||
* @param pluginId 插件 id
|
||||
export function usePluginAvailable(pluginId: string, initial = true) {
|
||||
/** 插件是否可用(默认 true,避免首帧闪现插件不可用占位;需要先置 false 的页面传 initial=false) */
|
||||
const available = ref(initial)
|
||||
/** 是否校验中 */
|
||||
const checking = ref(false)
|
||||
|
||||
/**
|
||||
* 执行插件可用性校验(刷新 available)
|
||||
* @returns 当前是否可用(与 available.value 一致,便于一次性调用方直接取返回值)
|
||||
*/
|
||||
export async function usePluginAvailable(pluginId: string): Promise<boolean> {
|
||||
return checkNeedPluginAvailable(pluginId)
|
||||
async function check(): Promise<boolean> {
|
||||
checking.value = true
|
||||
try {
|
||||
available.value = await checkNeedPluginAvailable(pluginId)
|
||||
return available.value
|
||||
}
|
||||
finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { available, checking, check }
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import { getPostByName, getPostCommentReplyList, postTrackersCounter, submitUpvo
|
||||
import { createVerificationCode, requestRestrictReadCheck } from '@/api/uni-halo'
|
||||
import { formatTime } from '@/utils/formatTime'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useFavoritesStore } from '@/store/favorites'
|
||||
import { useSettingStore } from '@/store/setting'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { buildPostFavoriteItem } from '@/utils/favorite'
|
||||
import { checkPostRestrictRead, copyToClipboard, getRestrictReadTypeName, getShowableContent } from '@/utils/restrictRead'
|
||||
import { getDomainOnly } from '@/utils/urlParams'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
@@ -23,6 +25,7 @@ definePage({
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const favoritesStore = useFavoritesStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
|
||||
@@ -217,6 +220,22 @@ async function handleDoLikes() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 收藏 ---------------- */
|
||||
/** 当前文章是否已收藏 */
|
||||
function hasFavorited(): boolean {
|
||||
const name = result.value?.metadata.name
|
||||
return !!name && favoritesStore.isFavorite('post', name)
|
||||
}
|
||||
|
||||
/** 切换收藏(收藏/取消),收藏时按当前文章内容生成快照入库 */
|
||||
function handleTogglePostFavorite() {
|
||||
const post = result.value
|
||||
if (!post)
|
||||
return
|
||||
const favorited = favoritesStore.toggle(buildPostFavoriteItem(post))
|
||||
uni.showToast({ icon: 'none', title: favorited ? '收藏成功' : '已取消收藏' })
|
||||
}
|
||||
|
||||
/* ---------------- 受限阅读 ---------------- */
|
||||
function readMore() {
|
||||
const annotations = result.value?.metadata?.annotations
|
||||
@@ -613,10 +632,11 @@ const globalAppSettings = computed(() => settingStore.settings)
|
||||
</view>
|
||||
<view
|
||||
class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none"
|
||||
@click="handleToComment()"
|
||||
@click="handleTogglePostFavorite"
|
||||
>
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-smile-" size="36rpx" />
|
||||
<text class="shrink-0 text-sm text-gray-900 font-semibold">收藏</text>
|
||||
<text class="shrink-0 text-sm text-gray-900 font-semibold"
|
||||
:style="hasFavorited() ? { color: '#ffb300' } : ''">{{ hasFavorited() ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,75 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 数据看板页(源自旧项目 pagesA/data-visual,新建复刻)
|
||||
* 标签统计/分类统计(环形图)、文章发布趋势(热度图)、评论活跃用户(柱状图)、热门文章 Top10(柱状图)
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getChartData } from '@/api/uni-halo'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IDataStatistics } from '@/api/uni-halo'
|
||||
import { ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getChartData } from '@/api/uni-halo'
|
||||
import type { IDataStatistics } from '@/api/uni-halo'
|
||||
|
||||
definePage({
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '数据看板',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/** 依赖插件(plugin-data-statistics) */
|
||||
const uniHaloPluginId = 'plugin-data-statistics'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
/** 依赖插件(plugin-data-statistics) */
|
||||
const uniHaloPluginId = 'plugin-data-statistics'
|
||||
const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const statistics = ref<IDataStatistics>({
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const statistics = ref<IDataStatistics>({
|
||||
tags: [],
|
||||
categories: [],
|
||||
articles: [],
|
||||
comments: [],
|
||||
top10Articles: [],
|
||||
})
|
||||
})
|
||||
|
||||
/* ---------------- 图表配置 ---------------- */
|
||||
const chartColors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#ea7ccc', '#0EA5E9']
|
||||
/* ---------------- 图表配置 ---------------- */
|
||||
const chartColors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#ea7ccc', '#0EA5E9']
|
||||
|
||||
/** 标签统计(环形图) */
|
||||
const tagChart = ref({
|
||||
/** 标签统计(环形图) */
|
||||
const tagChart = ref({
|
||||
isExpand: true,
|
||||
type: 'ring',
|
||||
data: { series: [{ data: [] as { name: string, value: number }[] }] },
|
||||
})
|
||||
data: { series: [{ data: [] as { name : string, value : number }[] }] },
|
||||
})
|
||||
|
||||
/** 分类统计(柱状图) */
|
||||
const categoryChart = ref({
|
||||
/** 分类统计(柱状图) */
|
||||
const categoryChart = ref({
|
||||
isExpand: true,
|
||||
type: 'column',
|
||||
data: { categories: [] as string[], series: [{ name: '分类', data: [] as number[] }] },
|
||||
})
|
||||
})
|
||||
|
||||
/** 文章发布趋势(热度图) */
|
||||
const trandArticleChart = ref({
|
||||
/** 文章发布趋势(热度图) */
|
||||
const trandArticleChart = ref({
|
||||
isExpand: true,
|
||||
type: 'hotmap',
|
||||
data: [] as { date: string, count: number }[],
|
||||
})
|
||||
data: [] as { date : string, count : number }[],
|
||||
})
|
||||
|
||||
/** 评论活跃用户(柱状图) */
|
||||
const userCommentsChart = ref({
|
||||
/** 评论活跃用户(柱状图) */
|
||||
const userCommentsChart = ref({
|
||||
isExpand: true,
|
||||
type: 'column',
|
||||
data: { categories: [] as string[], series: [{ name: '评论', data: [] as number[] }] },
|
||||
})
|
||||
})
|
||||
|
||||
/** 热门文章 Top10(柱状图) */
|
||||
const top10ArticlesChart = ref({
|
||||
/** 热门文章 Top10(柱状图) */
|
||||
const top10ArticlesChart = ref({
|
||||
isExpand: true,
|
||||
type: 'column',
|
||||
data: { categories: [] as string[], series: [{ name: '访问量', data: [] as number[] }] },
|
||||
})
|
||||
})
|
||||
|
||||
/* ---------------- 数据处理 ---------------- */
|
||||
function handleTagChart() {
|
||||
/* ---------------- 数据处理 ---------------- */
|
||||
function handleTagChart() {
|
||||
const data = [...statistics.value.tags].sort((a, b) => b.count - a.count)
|
||||
tagChart.value.data = {
|
||||
series: [
|
||||
@@ -78,41 +73,41 @@ function handleTagChart() {
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCategoriesChart() {
|
||||
function handleCategoriesChart() {
|
||||
const data = [...statistics.value.categories].sort((a, b) => b.total - a.total)
|
||||
categoryChart.value.data = {
|
||||
categories: data.map(item => item.name),
|
||||
series: [{ name: '分类', data: data.map(item => item.total) }],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrendArticlesChart() {
|
||||
function handleTrendArticlesChart() {
|
||||
trandArticleChart.value.data = statistics.value.articles.map(item => ({
|
||||
date: item.date,
|
||||
count: item.count,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function handleUserCommentsChart() {
|
||||
function handleUserCommentsChart() {
|
||||
const data = [...statistics.value.comments].sort((a, b) => b.count - a.count).slice(0, 10)
|
||||
userCommentsChart.value.data = {
|
||||
categories: data.map(item => item.username),
|
||||
series: [{ name: '评论', data: data.map(item => item.count) }],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTop10ArticlesChart() {
|
||||
function handleTop10ArticlesChart() {
|
||||
const data = [...statistics.value.top10Articles].sort((a, b) => b.views - a.views).slice(0, 10)
|
||||
top10ArticlesChart.value.data = {
|
||||
categories: data.map(item => item.name),
|
||||
series: [{ name: '访问量', data: data.map(item => item.views) }],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
uni.showLoading({ mask: true, title: '加载中...' })
|
||||
loading.value = 'loading'
|
||||
try {
|
||||
@@ -135,146 +130,127 @@ async function handleGetData() {
|
||||
uni.stopPullDownRefresh()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
async function init() {
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
async function init() {
|
||||
await checkPluginAvailable()
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
}
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
})
|
||||
})
|
||||
|
||||
init()
|
||||
init()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page box-border min-h-screen w-screen p-6 text-[#353437]" style="background-color: #fafafd;">
|
||||
<uh-plugin-unavailable
|
||||
v-if="!uniHaloPluginAvailable"
|
||||
:plugin-id="uniHaloPluginId"
|
||||
error-text="阿偶,检测到当前插件没有安装或者启用,无法使用功能哦,请联系管理员"
|
||||
@on-refresh="handleGetData"
|
||||
/>
|
||||
<view class="bg-page box-border min-h-screen w-screen p-3">
|
||||
<uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="uniHaloPluginId"
|
||||
error-text="阿偶,检测到当前插件没有安装或者启用,无法使用功能哦,请联系管理员" @on-refresh="handleGetData" />
|
||||
<template v-else>
|
||||
<!-- 加载/错误占位 -->
|
||||
<view v-if="loading !== 'success'">
|
||||
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
|
||||
</view>
|
||||
<uh-data-loading v-if="loading !== 'success'" :loading-status="loading" @refresh="handleGetData" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view v-else class="content flex flex-col gap-6">
|
||||
<view v-else class="content flex flex-col gap-3">
|
||||
<!-- 标签统计 -->
|
||||
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
|
||||
<view class="card-head flex items-center justify-between" @click="tagChart.isExpand = !tagChart.isExpand">
|
||||
<view class="card-head-title flex items-baseline gap-2">
|
||||
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">标签统计</text>
|
||||
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">(全部标签的文章数量占比)</text>
|
||||
<view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
|
||||
<uh-section-title>
|
||||
标签统计
|
||||
<template #right>
|
||||
<view class="flex items-center gap-x-2">
|
||||
<text class="text-xs text-gray-500">(全部标签的文章数量占比)</text>
|
||||
<wd-icon :name="tagChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
|
||||
@click="tagChart.isExpand = !tagChart.isExpand" />
|
||||
</view>
|
||||
<wd-icon :name="tagChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
|
||||
</view>
|
||||
<view v-show="tagChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
|
||||
<qiun-data-charts
|
||||
type="ring"
|
||||
:chart-data="tagChart.data"
|
||||
:opts="{ color: chartColors, padding: [5, 5, 5, 5], dataLabel: false, legend: { show: false }, extra: { ring: { ringWidth: 36, offsetAngle: -90, border: true, borderWidth: 1, borderColor: '#FFFFFF' } } }"
|
||||
/>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
<view v-show="tagChart.isExpand" class="box-border w-full mt-3">
|
||||
<qiun-data-charts type="ring" :chart-data="tagChart.data"
|
||||
:opts="{ color: chartColors, padding: [5, 5, 5, 5], dataLabel: false, legend: { show: false }, extra: { ring: { ringWidth: 36, offsetAngle: -90, border: true, borderWidth: 1, borderColor: '#FFFFFF' } } }" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分类统计 -->
|
||||
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
|
||||
<view class="card-head flex items-center justify-between" @click="categoryChart.isExpand = !categoryChart.isExpand">
|
||||
<view class="card-head-title flex items-baseline gap-2">
|
||||
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">分类统计</text>
|
||||
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">(全部分类的文章数量占比)</text>
|
||||
<view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
|
||||
<uh-section-title>
|
||||
分类统计
|
||||
<template #right>
|
||||
<view class="flex items-center gap-x-2">
|
||||
<text class="text-xs text-gray-500">(全部分类的文章数量占比)</text>
|
||||
<wd-icon :name="categoryChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
|
||||
@click="categoryChart.isExpand = !categoryChart.isExpand" />
|
||||
</view>
|
||||
<wd-icon :name="categoryChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
|
||||
</view>
|
||||
<view v-show="categoryChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
|
||||
<qiun-data-charts
|
||||
type="column"
|
||||
:chart-data="categoryChart.data"
|
||||
:opts="{ color: chartColors, padding: [20, 15, 10, 15], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 6 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
|
||||
/>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
<view v-show="categoryChart.isExpand" class="box-border w-full mt-3">
|
||||
<qiun-data-charts type="column" :chart-data="categoryChart.data"
|
||||
:opts="{ color: chartColors, padding: [20, 15, 10, 15], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 6 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 文章发布趋势 -->
|
||||
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
|
||||
<view class="card-head flex items-center justify-between" @click="trandArticleChart.isExpand = !trandArticleChart.isExpand">
|
||||
<view class="card-head-title flex items-baseline gap-2">
|
||||
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">文章发布趋势</text>
|
||||
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">(按日期统计文章发布数量)</text>
|
||||
<view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
|
||||
<uh-section-title>
|
||||
文章发布趋势
|
||||
<template #right>
|
||||
<view class="flex items-center gap-x-2">
|
||||
<text class="text-xs text-gray-500">(按日期统计文章发布数量)</text>
|
||||
<wd-icon :name="trandArticleChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
|
||||
@click="trandArticleChart.isExpand = !trandArticleChart.isExpand" />
|
||||
</view>
|
||||
<wd-icon :name="trandArticleChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
|
||||
</view>
|
||||
<view v-show="trandArticleChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
|
||||
</template>
|
||||
</uh-section-title>
|
||||
<view v-show="trandArticleChart.isExpand" class="box-border w-full mt-3">
|
||||
<uh-heatmap :chart-data="trandArticleChart.data" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 评论活跃用户 -->
|
||||
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
|
||||
<view class="card-head flex items-center justify-between" @click="userCommentsChart.isExpand = !userCommentsChart.isExpand">
|
||||
<view class="card-head-title flex items-baseline gap-2">
|
||||
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">评论活跃用户</text>
|
||||
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">(按评论作者统计评论数量)</text>
|
||||
<view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
|
||||
<uh-section-title>
|
||||
评论活跃用户
|
||||
<template #right>
|
||||
<view class="flex items-center gap-x-2">
|
||||
<text class="text-xs text-gray-500">(按评论作者统计评论数量)</text>
|
||||
<wd-icon :name="userCommentsChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
|
||||
@click="userCommentsChart.isExpand = !userCommentsChart.isExpand" />
|
||||
</view>
|
||||
<wd-icon :name="userCommentsChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
|
||||
</view>
|
||||
<view v-show="userCommentsChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
|
||||
<qiun-data-charts
|
||||
type="column"
|
||||
:chart-data="userCommentsChart.data"
|
||||
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
|
||||
/>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
<view v-show="userCommentsChart.isExpand" class="box-border w-full mt-3">
|
||||
<qiun-data-charts type="column" :chart-data="userCommentsChart.data"
|
||||
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 热门文章 Top10 -->
|
||||
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
|
||||
<view class="card-head">
|
||||
<view class="card-head-title flex items-baseline gap-2">
|
||||
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">热门文章前10</text>
|
||||
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">(按访问量排序的热门文章)</text>
|
||||
<view class="uh-global-card-glass uh-shadow-xs box-border rounded-xl p-4">
|
||||
<uh-section-title>
|
||||
热门文章前10
|
||||
<template #right>
|
||||
<view class="flex items-center gap-x-2">
|
||||
<text class="text-xs text-gray-500">(按访问量排序的热门文章)</text>
|
||||
<wd-icon :name="top10ArticlesChart.isExpand ? 'up' : 'down'" size="16px" color="#909399"
|
||||
@click="top10ArticlesChart.isExpand = !top10ArticlesChart.isExpand" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
|
||||
<qiun-data-charts
|
||||
type="column"
|
||||
:chart-data="top10ArticlesChart.data"
|
||||
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
|
||||
/>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
<view v-show="top10ArticlesChart.isExpand" class="box-border w-full mt-3">
|
||||
<qiun-data-charts type="column" :chart-data="top10ArticlesChart.data"
|
||||
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card-head-text {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8rpx;
|
||||
height: 70%;
|
||||
background-color: #03a9f4;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<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'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { FavoriteKind, IFavoriteItem } from '@/utils/favorite'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '我的收藏',
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const favoritesStore = useFavoritesStore()
|
||||
|
||||
/* ---------------- Tab(文章/瞬间) ---------------- */
|
||||
const activeKind = ref<FavoriteKind>('post')
|
||||
|
||||
const tabList = computed(() => [
|
||||
{ key: 'post' as FavoriteKind, label: '文章', count: favoritesStore.counts.post },
|
||||
{ key: 'moment' as FavoriteKind, label: '瞬间', count: favoritesStore.counts.moment },
|
||||
])
|
||||
|
||||
const currentItems = computed<IFavoriteItem[]>(() =>
|
||||
activeKind.value === 'post' ? favoritesStore.postItems : favoritesStore.momentItems,
|
||||
)
|
||||
|
||||
function handleSwitchTab(kind: FavoriteKind) {
|
||||
activeKind.value = kind
|
||||
}
|
||||
|
||||
/* ---------------- 加载状态机(与 tabbar 页同构) ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
|
||||
/** 本地数据同步可得:状态由当前 Tab 列表直接推导(空→Empty;loading/error 分支为将来异步数据源预留) */
|
||||
watchEffect(() => {
|
||||
updateLoadingStatus(
|
||||
currentItems.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
|
||||
)
|
||||
})
|
||||
|
||||
/** uh-data-loading「刷新试试」:空收藏无可刷新来源,引导回首页发现可收藏内容 */
|
||||
function handleExplore() {
|
||||
uni.switchTab({ url: '/pages/tabbar/home/home' })
|
||||
}
|
||||
|
||||
/* ---------------- 收藏时间 ---------------- */
|
||||
function formatCollectTime(time: string): string {
|
||||
return formatTime({ d: time, f: 'yyyy-MM-dd' })
|
||||
}
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
/** 跳转对应类型详情(原内容被删由详情页空态兜底) */
|
||||
function handleToDetail(item: IFavoriteItem) {
|
||||
const base = item.kind === 'post'
|
||||
? '/pages-blog/article-detail/article-detail'
|
||||
: '/pages-blog/moment-detail/moment-detail'
|
||||
uni.navigateTo({
|
||||
url: `${base}?name=${item.id}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
|
||||
/** 取消收藏(直接删除 + 轻提示;详情页可随时重新收藏,不弹二次确认) */
|
||||
function handleRemove(item: IFavoriteItem) {
|
||||
favoritesStore.remove(item.kind, item.id)
|
||||
uni.showToast({ icon: 'none', title: '已取消收藏' })
|
||||
}
|
||||
|
||||
/* ---------------- 空态文案 ---------------- */
|
||||
const emptyText = computed(() => (activeKind.value === 'post' ? '还没有收藏文章' : '还没有收藏瞬间'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="box-border min-h-screen w-screen bg-page pb-10">
|
||||
<!-- 顶部类型 Tab(与图库页同款:吸顶玻璃胶囊 chip) -->
|
||||
<wd-sticky>
|
||||
<scroll-view scroll-x class="w-full whitespace-nowrap">
|
||||
<view class="flex gap-2 px-3 pb-1 pt-3">
|
||||
<view
|
||||
v-for="tab in tabList" :key="tab.key"
|
||||
class="uh-global-card-glass uh-shadow-xs inline-block border rounded-2xl px-5 py-1.5 text-sm"
|
||||
:class="tab.key === activeKind ? 'bg-primary font-bold' : 'text-gray-500'"
|
||||
@click="handleSwitchTab(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<text v-if="tab.count > 0">({{ tab.count }})</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</wd-sticky>
|
||||
|
||||
<!-- 状态舞台 + 列表 -->
|
||||
<view class="flex flex-col gap-3 px-3 pt-2">
|
||||
<!-- 空态(当前 Tab 无收藏):uh-data-loading 统一渲染,视觉与 tabbar 页一致 -->
|
||||
<uh-data-loading
|
||||
v-if="loadingStatus !== 'success'" :loading-status="loadingStatus" min-height="55vh"
|
||||
:empty-text="emptyText" empty-sub-text="在文章或瞬间的详情页点亮星标,内容会出现在这里"
|
||||
@refresh="handleExplore"
|
||||
/>
|
||||
|
||||
<!-- 成功态:当前 Tab 列表 -->
|
||||
<template v-else>
|
||||
<!-- 文章卡 -->
|
||||
<template v-if="activeKind === 'post'">
|
||||
<view
|
||||
v-for="item in currentItems" :key="item.id"
|
||||
class="uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white" @click="handleToDetail(item)"
|
||||
>
|
||||
<view class="flex gap-3 p-4 pb-3">
|
||||
<image
|
||||
v-if="item.cover" :src="item.cover" mode="aspectFill"
|
||||
class="h-[128rpx] w-[176rpx] shrink-0 rounded-lg"
|
||||
/>
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="truncate text-sm text-gray-900 font-bold">
|
||||
{{ item.title || '未命名' }}
|
||||
</view>
|
||||
<view v-if="item.content" class="clamp-2 mt-1 text-xs text-gray-500 leading-relaxed">
|
||||
{{ item.content }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 元信息 + 底部操作(详情/删除) -->
|
||||
<view class="flex items-center justify-between px-4 pb-3">
|
||||
<view class="min-w-0 flex flex-1 items-center gap-1.5 text-xs text-gray-400">
|
||||
<image
|
||||
v-if="item.owner.avatar" :src="item.owner.avatar" mode="aspectFill"
|
||||
class="h-[36rpx] w-[36rpx] shrink-0 rounded-full"
|
||||
/>
|
||||
<text class="max-w-[220rpx] truncate">{{ item.owner.displayName }}</text>
|
||||
<text class="shrink-0">· 收藏于 {{ formatCollectTime(item.createTime) }}</text>
|
||||
</view>
|
||||
<view class="flex shrink-0 items-center gap-1">
|
||||
<text class="px-2 py-1 text-xs text-primary" @click.stop="handleToDetail(item)">详情</text>
|
||||
<text class="px-2 py-1 text-xs text-gray-400" @click.stop="handleRemove(item)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 瞬间卡 -->
|
||||
<template v-else>
|
||||
<view
|
||||
v-for="item in currentItems" :key="item.id"
|
||||
class="uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white" @click="handleToDetail(item)"
|
||||
>
|
||||
<view class="p-4 pb-3">
|
||||
<view class="clamp-3 text-sm text-gray-800 leading-relaxed">
|
||||
{{ item.content || '(暂无内容)' }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="flex items-center justify-between px-4 pb-3">
|
||||
<view class="min-w-0 flex flex-1 items-center gap-1.5 text-xs text-gray-400">
|
||||
<image
|
||||
v-if="item.owner.avatar" :src="item.owner.avatar" mode="aspectFill"
|
||||
class="h-[36rpx] w-[36rpx] shrink-0 rounded-full"
|
||||
/>
|
||||
<text class="max-w-[220rpx] truncate">{{ item.owner.displayName }}</text>
|
||||
<text class="shrink-0">· 收藏于 {{ formatCollectTime(item.createTime) }}</text>
|
||||
</view>
|
||||
<view class="flex shrink-0 items-center gap-1">
|
||||
<text class="px-2 py-1 text-xs text-primary" @click.stop="handleToDetail(item)">详情</text>
|
||||
<text class="px-2 py-1 text-xs text-gray-400" @click.stop="handleRemove(item)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 多行截断(原子类无 line-clamp,scoped 补充) */
|
||||
.clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,7 @@ import { getMiniProgramLinkGroupedList } from '@/api/uni-halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useSettingStore } from '@/store/setting'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { NeedPluginIds, usePluginAvailable } from '@/utils/plugin'
|
||||
import { NeedPluginIds } from '@/hooks/usePluginAvailable'
|
||||
import type { ILink, ILinkGroup } from '@/api/types/halo'
|
||||
import type { IMiniProgramLink, IMiniProgramLinkGroupVo } from '@/api/types/uni-halo'
|
||||
|
||||
@@ -32,10 +32,10 @@ const globalAppSettings = computed(() => settingStore.settings)
|
||||
/* ---------------- 依赖插件 ---------------- */
|
||||
/** 站点 tab:plugin-links */
|
||||
const sitePluginId = NeedPluginIds.PluginLinks
|
||||
const sitePluginAvailable = ref(true)
|
||||
const { available: sitePluginAvailable, check: checkSitePluginAvailable } = usePluginAvailable(sitePluginId)
|
||||
/** 小程序 tab:plugin-uni-halo */
|
||||
const miniPluginId = NeedPluginIds.PluginUniHalo
|
||||
const miniPluginAvailable = ref(true)
|
||||
const { available: miniPluginAvailable, check: checkMiniPluginAvailable } = usePluginAvailable(miniPluginId)
|
||||
|
||||
/* ---------------- tabs ---------------- */
|
||||
const activeTabIndex = ref(0)
|
||||
@@ -268,9 +268,9 @@ function handleSaveMiniProgramCode(link: IMiniProgramLink) {
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
;[sitePluginAvailable.value, miniPluginAvailable.value] = await Promise.all([
|
||||
usePluginAvailable(sitePluginId),
|
||||
usePluginAvailable(miniPluginId),
|
||||
await Promise.all([
|
||||
checkSitePluginAvailable(),
|
||||
checkMiniPluginAvailable(),
|
||||
])
|
||||
if (sitePluginAvailable.value)
|
||||
handleGetLinkGroupData()
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { getMomentByName } from '@/api/halo'
|
||||
import { getMomentByName, submitUpvote } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useFavoritesStore } from '@/store/favorites'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { buildMomentFavoriteItem } from '@/utils/favorite'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
@@ -27,6 +29,7 @@ definePage({
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const favoritesStore = useFavoritesStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
|
||||
const bloggerInfo = computed(() => {
|
||||
@@ -122,6 +125,83 @@ const calcMastheadMeta = computed(() => {
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年 · 星期w' }) : ''
|
||||
})
|
||||
|
||||
/* ---------------- 收藏 ---------------- */
|
||||
/** 当前瞬间是否已收藏(悬浮胶囊高亮) */
|
||||
const momentFavorited = computed(() => {
|
||||
const name = moment.value?.metadata.name
|
||||
return !!name && favoritesStore.isFavorite('moment', name)
|
||||
})
|
||||
|
||||
/** 切换收藏(收藏/取消),收藏时按当前详情内容生成快照入库 */
|
||||
function handleToggleMomentFavorite() {
|
||||
const card = moment.value
|
||||
if (!card)
|
||||
return
|
||||
const favorited = favoritesStore.toggle(buildMomentFavoriteItem(card))
|
||||
uni.showToast({ icon: 'none', title: favorited ? '收藏成功' : '已取消收藏' })
|
||||
}
|
||||
|
||||
/* ---------------- 点赞 ---------------- */
|
||||
const upvotedNames = ref<string[]>([])
|
||||
|
||||
function hasUpvoted(): boolean {
|
||||
return upvotedNames.value.includes(moment.value?.metadata.name || '')
|
||||
}
|
||||
|
||||
async function handleDoLikes() {
|
||||
const current = moment.value
|
||||
if (!current)
|
||||
return
|
||||
if (hasUpvoted()) {
|
||||
uni.showToast({ icon: 'none', title: '已经点过赞啦!' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await submitUpvote({
|
||||
group: 'content.halo.run',
|
||||
plural: 'moments',
|
||||
name: current.metadata.name,
|
||||
})
|
||||
uni.showToast({ icon: 'none', title: '点赞成功!' })
|
||||
upvotedNames.value.push(current.metadata.name)
|
||||
if (current.stats) {
|
||||
current.stats.upvote = (current.stats.upvote || 0) + 1
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('点赞失败', err)
|
||||
uni.showToast({ icon: 'none', title: '点赞失败' })
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 评论 ---------------- */
|
||||
const commentModal = ref({
|
||||
show: false,
|
||||
isComment: false,
|
||||
postName: '',
|
||||
title: '',
|
||||
})
|
||||
|
||||
function handleToComment() {
|
||||
const current = moment.value
|
||||
if (!current)
|
||||
return
|
||||
if (!current.spec.allowComment) {
|
||||
uni.showToast({ icon: 'none', title: '瞬间已开启禁止评论!' })
|
||||
return
|
||||
}
|
||||
commentModal.value = {
|
||||
show: true,
|
||||
isComment: true,
|
||||
postName: current.metadata.name,
|
||||
title: '新增评论',
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnCommentModalClose(data: { refresh: boolean, isSubmit: boolean }) {
|
||||
commentModal.value.show = false
|
||||
}
|
||||
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(videos: { id?: string }[]) {
|
||||
stopAllVideos()
|
||||
@@ -329,9 +409,49 @@ onShareTimeline(() => ({
|
||||
<view v-else class="h-7" />
|
||||
</view>
|
||||
|
||||
<!-- 悬浮操作(与文章详情一致:点赞/评论/收藏) -->
|
||||
<view class="fixed bottom-8 left-1/2 z-10 flex items-center justify-center pb-safe -translate-x-1/2">
|
||||
<view
|
||||
class="uh-global-card-glass box-border flex items-center justify-center gap-2 border rounded-full p-1 text-primary"
|
||||
>
|
||||
<!-- 点赞 -->
|
||||
<view
|
||||
class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none"
|
||||
:class="{ active: hasUpvoted() }" @click="handleDoLikes"
|
||||
>
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-kiss-" size="36rpx" />
|
||||
<text class="shrink-0 text-sm text-gray-900 font-semibold">点赞</text>
|
||||
</view>
|
||||
<!-- 评论 -->
|
||||
<view
|
||||
class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none"
|
||||
@click="handleToComment()"
|
||||
>
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="36rpx" />
|
||||
<text class="shrink-0 text-sm text-gray-900 font-semibold">评论</text>
|
||||
</view>
|
||||
<!-- 收藏 -->
|
||||
<view
|
||||
class="uh-global-card-glass box-border h-[72rpx] flex flex-1 items-center justify-center gap-x-1 border rounded-full px-4 shadow-none"
|
||||
@click="handleToggleMomentFavorite"
|
||||
>
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-smile-" size="36rpx" />
|
||||
<text class="shrink-0 text-sm text-gray-900 font-semibold"
|
||||
:style="momentFavorited ? { color: '#ffb300' } : ''">{{ momentFavorited ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</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>
|
||||
|
||||
<!-- 评论弹窗 -->
|
||||
<uh-comment-modal
|
||||
v-if="commentModal.show" :show="commentModal.show" :is-comment="commentModal.isComment"
|
||||
:title="commentModal.title" :post-name="commentModal.postName" subject-kind="Moment"
|
||||
@on-close="handleOnCommentModalClose"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getPostListByKeyword } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { debounce } from '@/utils/debounce'
|
||||
@@ -24,7 +23,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
|
||||
/** 依赖插件(plugin-search-widget) */
|
||||
const uniHaloPluginId = 'plugin-search-widget'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
@@ -122,7 +121,7 @@ function handleToTopPage(duration = 500) {
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
await checkPluginAvailable()
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
|
||||
@@ -7,7 +7,6 @@ import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getVoteList } from '@/api/uni-halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IVoteItem } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
@@ -22,7 +21,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
|
||||
/** 依赖插件(plugin-vote) */
|
||||
const uniHaloPluginId = 'plugin-vote'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
@@ -84,7 +83,7 @@ function handleToTopPage(duration = 500) {
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
await checkPluginAvailable()
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkUrl } from '@/utils/url'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IPublicMaintenance } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
@@ -23,6 +22,8 @@
|
||||
const RECOVERY_POLL_INTERVAL = 30 * 1000
|
||||
|
||||
const store = useAppConfigStore()
|
||||
/** 插件可用性(拦截恢复检测用) */
|
||||
const { check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
|
||||
|
||||
const viewState = ref<ViewState>('loading')
|
||||
const maintenance = ref<IPublicMaintenance | null>(null)
|
||||
@@ -146,7 +147,7 @@
|
||||
try {
|
||||
await store.bootstrap({ force: true })
|
||||
if (fromReason.value === 'plugin') {
|
||||
const available = await usePluginAvailable(uniHaloPluginId)
|
||||
const available = await checkPluginAvailable()
|
||||
if (!available) {
|
||||
const info = store.configs.maintenance
|
||||
if (info) {
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title + 彩色图标块)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
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 { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IBlogStats } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
@@ -23,10 +23,13 @@ definePage({
|
||||
})
|
||||
|
||||
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(() => {
|
||||
@@ -116,10 +119,28 @@ 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} 条收藏`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetNavList() {
|
||||
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
|
||||
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: '数据看板',
|
||||
@@ -217,6 +238,7 @@ async function handleGetNavList() {
|
||||
group: 'more',
|
||||
},
|
||||
]
|
||||
syncFavoritesNavText()
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
@@ -269,6 +291,11 @@ watch(haloConfigs, () => {
|
||||
|
||||
handleGetData()
|
||||
|
||||
// 从收藏页返回/切回时刷新收藏数文案
|
||||
onShow(() => {
|
||||
syncFavoritesNavText()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { IPhoto, IPhotoGroup } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
@@ -27,10 +27,10 @@
|
||||
|
||||
/** 依赖插件(plugin-photos) */
|
||||
const uniHaloPluginId = 'plugin-photos'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId, false)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error' | 'empty'>('loading')
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const category = ref<{ activeIndex : number, list : IPhotoGroup[] }>({
|
||||
activeIndex: 0,
|
||||
list: [],
|
||||
@@ -58,14 +58,12 @@
|
||||
handleGetData(true)
|
||||
}
|
||||
else {
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
loading.value = 'error'
|
||||
category.value = { activeIndex: 0, list: [] }
|
||||
}
|
||||
return
|
||||
@@ -82,7 +80,6 @@
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
loading.value = 'error'
|
||||
category.value = { activeIndex: 0, list: [] }
|
||||
}
|
||||
}
|
||||
@@ -94,7 +91,7 @@
|
||||
}
|
||||
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = ''
|
||||
|
||||
@@ -110,12 +107,12 @@
|
||||
? dataList.value.concat(list)
|
||||
: list
|
||||
}
|
||||
loading.value = dataList.value.length !== 0 ? 'success' : 'empty'
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
@@ -150,19 +147,17 @@
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
// 检查插件可用性
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
await checkPluginAvailable()
|
||||
console.log('uniHaloPluginAvailable',uniHaloPluginAvailable.value)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
watch(galleryConfig, (newVal) => {
|
||||
if (!newVal)
|
||||
return
|
||||
uni.setNavigationBarTitle({ title: newVal.pageTitle || t('page.gallery.title') })
|
||||
|
||||
// 开始正常数据请求
|
||||
handleGetCategory()
|
||||
}, { deep: true, immediate: true })
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
@@ -211,9 +206,8 @@
|
||||
</wd-sticky>
|
||||
|
||||
<!-- 加载/错误占位 -->
|
||||
<view v-if="loading !== 'success'" class="box-border p-3">
|
||||
<uh-data-loading :loading-status="loading" @refresh="handleGetCategory" />
|
||||
</view>
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
@refresh="handleGetCategory" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view v-else class="box-border w-full p-3">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import { useMaintenanceIntercept } from '@/hooks/useMaintenanceIntercept'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { IPost } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
@@ -28,7 +29,7 @@
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const articleList = ref<IPost[]>([])
|
||||
@@ -79,12 +80,12 @@
|
||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
||||
return item
|
||||
})
|
||||
loading.value = 'success'
|
||||
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = t('common.noMore')
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取审核文章失败', err)
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
@@ -95,7 +96,7 @@
|
||||
}
|
||||
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
@@ -108,11 +109,11 @@
|
||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
||||
return item
|
||||
})
|
||||
loading.value = 'success'
|
||||
updateLoadingStatus(articleList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
}
|
||||
catch (err) {
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
console.error('获取文章失败', err)
|
||||
}
|
||||
@@ -148,7 +149,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
function init(){
|
||||
function init() {
|
||||
if (!intercepted.value) {
|
||||
handleQuery()
|
||||
}
|
||||
@@ -183,17 +184,10 @@
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="min-h-screen w-screen flex flex-col bg-page">
|
||||
<!-- 加载/错误占位 -->
|
||||
<uh-data-loading v-if="loading !== 'success' && articleList.length === 0" :loading-status="loading"
|
||||
@refresh="handleQuery" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<block v-else>
|
||||
<!-- 轮播 -->
|
||||
<uh-home-banner />
|
||||
|
||||
@@ -217,9 +211,10 @@
|
||||
</template>
|
||||
</uh-section-title>
|
||||
|
||||
<view v-if="articleList.length === 0" class="article-empty py-10">
|
||||
<wd-empty description="博主还没有发表任何内容~" />
|
||||
</view>
|
||||
<!-- 加载/错误占位 -->
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
min-height="36vh" @refresh="handleQuery" />
|
||||
|
||||
<block v-else>
|
||||
<view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
|
||||
<uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
|
||||
@@ -232,7 +227,6 @@
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
</view>
|
||||
</block>
|
||||
</block>
|
||||
</view>
|
||||
<uh-notify-dialog />
|
||||
</template>
|
||||
@@ -8,12 +8,14 @@
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useFavoritesStore } from '@/store/favorites'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { buildMomentFavoriteItem } from '@/utils/favorite'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { formatTime } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
@@ -21,12 +23,11 @@
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const favoritesStore = useFavoritesStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
@@ -47,13 +48,13 @@
|
||||
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
const { available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable(uniHaloPluginId)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
|
||||
/** 列表卡片 */
|
||||
type MomentCard = IMoment & {
|
||||
images ?: { type ?: string, url : string }[]
|
||||
videos ?: { id ?: string, url : string }[]
|
||||
@@ -66,13 +67,6 @@
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
|
||||
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
|
||||
const calcTagColors = computed(() => {
|
||||
return dataList.value.map(moment =>
|
||||
(moment.spec.tags || []).map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f')),
|
||||
)
|
||||
})
|
||||
|
||||
/** 移除内容中的 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString : string) : string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
@@ -116,14 +110,14 @@
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
@@ -131,13 +125,12 @@
|
||||
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
try {
|
||||
const res = await getMomentList({ ...queryParams.value })
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
|
||||
@@ -148,6 +141,7 @@
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
@@ -155,7 +149,7 @@
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
@@ -216,6 +210,18 @@
|
||||
})
|
||||
}
|
||||
|
||||
/** 是否已收藏该瞬间(卡片收藏格高亮) */
|
||||
function isMomentFavorite(moment : IMoment) : boolean {
|
||||
return favoritesStore.isFavorite('moment', moment.metadata.name)
|
||||
}
|
||||
|
||||
/** 切换收藏(收藏/取消),收藏时按当前卡片内容生成快照入库 */
|
||||
function handleToggleMomentFavorite(moment : MomentCard) {
|
||||
if (!moment) { return }
|
||||
const favorited = favoritesStore.toggle(buildMomentFavoriteItem(moment))
|
||||
uni.showToast({ icon: 'none', title: favorited ? '收藏成功' : '已取消收藏' })
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
@@ -228,14 +234,13 @@
|
||||
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time ?: string) : string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
return time ? formatTime({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
await checkPluginAvailable()
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
@@ -256,8 +261,7 @@
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (!uniHaloPluginAvailable.value) { return }
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
@@ -279,8 +283,8 @@
|
||||
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员" @on-refresh="handleGetData" />
|
||||
<template v-else>
|
||||
<!-- 加载失败(可重试) -->
|
||||
<uh-data-loading v-if="loading !== 'success'" :loading-status="loading" min-height="60vh"
|
||||
@refresh="handleGetData" />
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
min-height="60vh" @refresh="handleGetData" />
|
||||
|
||||
<view v-else class="flex flex-col gap-3 px-3">
|
||||
<view v-if="dataList.length === 0"
|
||||
@@ -289,7 +293,7 @@
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(社交信息流:着色昵称 + 朋友圈式不缩进正文 + 媒体九宫格 + 内嵌互动脚注) -->
|
||||
<!-- 瞬间卡片-->
|
||||
<view v-for="moment in dataList" :key="moment.metadata.name"
|
||||
class="moment-card uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white">
|
||||
<!-- 作者 -->
|
||||
@@ -372,9 +376,10 @@
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
|
||||
<text class="text-sm text-gray-600">评论 {{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-x-1">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
|
||||
<text class="text-sm text-gray-600">收藏</text>
|
||||
<view class="flex items-center gap-x-1" @click.stop="handleToggleMomentFavorite(moment)">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-smile-" size="32rpx" />
|
||||
<text class="text-sm text-gray-600"
|
||||
:style="isMomentFavorite(moment) ? { color: '#ffb300' } : ''">{{ isMomentFavorite(moment) ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 瞬间页(源自旧项目 pages/tabbar/moments/moments.vue,新建复刻)
|
||||
* 功能:瞬间卡片列表(头像/内容/图片/音频/视频/标签) + 分页加载
|
||||
* 设计:固定壁纸光斑层为卡片毛玻璃取色(苹果风玻璃拟态)
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } 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 { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色,壁纸光斑由页面内提供
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
}
|
||||
})
|
||||
|
||||
/** 站点名称(原 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'
|
||||
})
|
||||
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
/** 列表卡片: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 }
|
||||
}
|
||||
const dataList = ref<MomentCard[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
|
||||
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
|
||||
const calcTagColors = computed(() => {
|
||||
return dataList.value.map(moment =>
|
||||
(moment.spec.tags || []).map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f')),
|
||||
)
|
||||
})
|
||||
|
||||
/** 移除内容中的 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString: string): string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
|
||||
/** 瞬间项映射(spec.content.medium 拆分为 images/videos/audios + 内容 tag 清理 + 作者兜底) */
|
||||
function mapMomentItem(item: IMoment): MomentCard {
|
||||
const medium = (item.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = item.owner
|
||||
return {
|
||||
...item,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...item.spec,
|
||||
newHtml: removeTagLinksCompletely(item.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'),
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
// 审核模式:真实瞬间按 audit-data moments 过滤(数组顺序即展示顺序)
|
||||
const auditMomentNames = appConfigStore.auditData.spec?.moments || []
|
||||
try {
|
||||
const res = await getMomentList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC' && auditMomentNames.includes(x.metadata.name))
|
||||
const orderMap = new Map(auditMomentNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
const tempItems = filtered.map(mapMomentItem)
|
||||
dataList.value = tempItems
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
try {
|
||||
const res = await getMomentList({ ...queryParams.value })
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
|
||||
const tempItems = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC')
|
||||
.map(mapMomentItem)
|
||||
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(list: { videos?: { id?: string }[] }[]) {
|
||||
stopAllVideos()
|
||||
list.map(item => item.videos || []).flat().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 handleToMomentDetail(moment: IMoment) {
|
||||
if (calcAuditModeEnabled.value)
|
||||
return
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time?: string): string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="relative box-border min-h-screen w-screen flex flex-col bg-page py-6">
|
||||
<!-- 壁纸(柔和光斑为卡片毛玻璃取色,固定不随滚动) -->
|
||||
<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.15)] -left-[120rpx] -top-[60rpx]" />
|
||||
<view class="absolute top-[260rpx] h-[360rpx] w-[360rpx] rounded-full bg-[rgba(244,143,177,0.14)] -right-[130rpx]" />
|
||||
<view class="absolute top-[700rpx] h-[400rpx] w-[400rpx] rounded-full bg-[rgba(179,157,219,0.13)] -left-[150rpx]" />
|
||||
<view class="absolute top-[1120rpx] h-[380rpx] w-[380rpx] rounded-full bg-[rgba(77,208,235,0.12)] -right-[110rpx]" />
|
||||
<view class="absolute left-[180rpx] top-[1560rpx] h-[420rpx] w-[420rpx] rounded-full bg-[rgba(185,228,36,0.14)]" />
|
||||
</view>
|
||||
|
||||
<uh-plugin-unavailable
|
||||
v-if="!uniHaloPluginAvailable"
|
||||
:plugin-id="uniHaloPluginId"
|
||||
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员"
|
||||
@on-refresh="handleGetData"
|
||||
/>
|
||||
<template v-else>
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading === 'loading'" class="loading-wrap p-3">
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</view>
|
||||
|
||||
<!-- 加载失败(可重试) -->
|
||||
<uh-data-loading
|
||||
v-else-if="loading === 'error'"
|
||||
:loading-status="loading"
|
||||
min-height="60vh"
|
||||
error-text="瞬间加载失败,请点击重试"
|
||||
@refresh="handleGetData"
|
||||
/>
|
||||
|
||||
<view v-else class="relative z-1 flex flex-col gap-y-4 p-4">
|
||||
<view v-if="dataList.length === 0" class="min-h-[70vh] w-full flex items-center justify-center content-empty">
|
||||
<wd-empty :description="t('common.empty')" />
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(玻璃) -->
|
||||
<view v-for="(moment, mIndex) in dataList" :key="moment.metadata.name" class="uh-global-card-glass flex flex-col overflow-hidden rounded-[32rpx]">
|
||||
<view class="head flex items-center p-3 pb-0">
|
||||
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 border-2 border-white/80 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="nickname ml-3">
|
||||
<view class="nickname-text text-[30rpx] text-gray-900 font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="release-time mt-1 text-[24rpx] text-gray-400">
|
||||
{{ formatMomentTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="moment-content px-3 py-2" @click.stop="handleToMomentDetail(moment)">
|
||||
<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="images flex flex-wrap items-start px-3 pb-4">
|
||||
<view
|
||||
v-for="(image, mediumIndex) in moment.images"
|
||||
:key="mediumIndex"
|
||||
class="image-item box-border p-1"
|
||||
: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 v-if="moment.audios && moment.audios.length !== 0" class="audio-list mb-3 flex flex-col gap-3 px-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 v-if="moment.videos && moment.videos.length !== 0" class="video-list mb-3 flex flex-col gap-3 px-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.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-2 px-3 pb-4 pt-1">
|
||||
<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[mIndex]?.[tagIndex] }">
|
||||
# {{ tag }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 互动数据(点赞/评论) -->
|
||||
<view class="flex items-center justify-end gap-2 px-4 pb-4">
|
||||
<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 class="to-top-btn uh-global-card-glass fixed bottom-[120rpx] 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 class="load-text pb-5 text-center text-[24rpx] text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { useFavoritesStore } from './favorites'
|
||||
import type { FavoriteKind, IFavoriteItem } from '@/utils/favorite'
|
||||
|
||||
/** 构造合法收藏项(createTime 默认递增,保证字典序即可乱序测试) */
|
||||
let seq = 0
|
||||
function makeItem(kind: FavoriteKind, id: string, createTime?: string): IFavoriteItem {
|
||||
seq += 1
|
||||
return {
|
||||
kind,
|
||||
id,
|
||||
title: kind === 'post' ? `标题${id}` : undefined,
|
||||
cover: kind === 'post' ? 'https://example.com/cover.png' : undefined,
|
||||
content: `内容${id}-${seq}`,
|
||||
createTime: createTime || new Date(2026, 8, seq).toISOString(),
|
||||
owner: { id: 'owner-1', displayName: '博主', avatar: 'https://example.com/a.png' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFavoritesStore', () => {
|
||||
it('add:文章与瞬间分类存储并统计数量', () => {
|
||||
const store = useFavoritesStore()
|
||||
store.add(makeItem('post', 'p-1'))
|
||||
store.add(makeItem('moment', 'm-1'))
|
||||
|
||||
expect(store.postItems.map(i => i.id)).toEqual(['p-1'])
|
||||
expect(store.momentItems.map(i => i.id)).toEqual(['m-1'])
|
||||
expect(store.counts).toEqual({ post: 1, moment: 1, total: 2 })
|
||||
})
|
||||
|
||||
it('postItems/momentItems:按收藏时间倒序排列', () => {
|
||||
const store = useFavoritesStore()
|
||||
store.add(makeItem('post', 'p-old', '2026-08-01T00:00:00.000Z'))
|
||||
store.add(makeItem('post', 'p-new', '2026-09-01T00:00:00.000Z'))
|
||||
store.add(makeItem('moment', 'm-old', '2026-07-01T00:00:00.000Z'))
|
||||
store.add(makeItem('moment', 'm-new', '2026-10-01T00:00:00.000Z'))
|
||||
|
||||
expect(store.postItems.map(i => i.id)).toEqual(['p-new', 'p-old'])
|
||||
expect(store.momentItems.map(i => i.id)).toEqual(['m-new', 'm-old'])
|
||||
})
|
||||
|
||||
it('add:同一 kind+id 重复收藏幂等忽略', () => {
|
||||
const store = useFavoritesStore()
|
||||
const item = makeItem('post', 'p-1')
|
||||
expect(store.add(item)).toBe(true)
|
||||
expect(store.add(item)).toBe(false)
|
||||
expect(store.postItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('add:文章与瞬间允许相同 id(不同 kind 互不冲突)', () => {
|
||||
const store = useFavoritesStore()
|
||||
store.add(makeItem('post', 'same'))
|
||||
store.add(makeItem('moment', 'same'))
|
||||
|
||||
expect(store.counts.total).toBe(2)
|
||||
expect(store.isFavorite('post', 'same')).toBe(true)
|
||||
expect(store.isFavorite('moment', 'same')).toBe(true)
|
||||
|
||||
expect(store.remove('post', 'same')).toBe(true)
|
||||
expect(store.isFavorite('moment', 'same')).toBe(true)
|
||||
expect(store.counts.moment).toBe(1)
|
||||
})
|
||||
|
||||
it('isFavorite:未收藏返回 false,收藏后返回 true', () => {
|
||||
const store = useFavoritesStore()
|
||||
expect(store.isFavorite('moment', 'm-1')).toBe(false)
|
||||
store.add(makeItem('moment', 'm-1'))
|
||||
expect(store.isFavorite('moment', 'm-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggle:未收藏→收藏返回 true;已收藏→取消返回 false', () => {
|
||||
const store = useFavoritesStore()
|
||||
expect(store.toggle(makeItem('moment', 'm-1'))).toBe(true)
|
||||
expect(store.isFavorite('moment', 'm-1')).toBe(true)
|
||||
expect(store.toggle(makeItem('moment', 'm-1'))).toBe(false)
|
||||
expect(store.isFavorite('moment', 'm-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('remove:删除存在的项返回 true,重复删除返回 false', () => {
|
||||
const store = useFavoritesStore()
|
||||
store.add(makeItem('post', 'p-1'))
|
||||
expect(store.remove('post', 'p-1')).toBe(true)
|
||||
expect(store.postItems).toHaveLength(0)
|
||||
expect(store.remove('post', 'p-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('add:结构非法的收藏项被拒绝', () => {
|
||||
const store = useFavoritesStore()
|
||||
expect(store.add({} as IFavoriteItem)).toBe(false)
|
||||
expect(store.add({ kind: 'post' } as IFavoriteItem)).toBe(false)
|
||||
expect(store.list).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('脏数据:持久化读回的非法项不进入列表与统计', () => {
|
||||
const store = useFavoritesStore()
|
||||
store.add(makeItem('post', 'p-ok', '2026-09-01T00:00:00.000Z'))
|
||||
// 模拟 storage 中残留的结构非法项(绕过 add 直接写入)
|
||||
store.list.push({
|
||||
kind: 'post',
|
||||
id: '',
|
||||
content: 'bad',
|
||||
createTime: '2026-09-02T00:00:00.000Z',
|
||||
owner: { displayName: '' },
|
||||
} as unknown as IFavoriteItem)
|
||||
store.list.push({ not: 'an-item' } as unknown as IFavoriteItem)
|
||||
|
||||
expect(store.postItems.map(i => i.id)).toEqual(['p-ok'])
|
||||
expect(store.counts).toEqual({ post: 1, moment: 0, total: 1 })
|
||||
expect(store.isFavorite('post', 'p-ok')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { favoriteKey, isValidFavoriteItem } from '@/utils/favorite'
|
||||
import type { FavoriteKind, IFavoriteItem } from '@/utils/favorite'
|
||||
|
||||
/**
|
||||
* 本地收藏 store(文章/瞬间统一格式,persist 持久化)
|
||||
* - 纯本地:收藏数据自包含快照,不依赖接口回源
|
||||
* - 去重键 kind + ':' + id;持久化读回数据为边界数据,展示前经 isValidFavoriteItem 过滤
|
||||
*/
|
||||
export const useFavoritesStore = defineStore(
|
||||
'favorites',
|
||||
() => {
|
||||
/** 收藏列表(存储顺序为插入顺序,展示按 createTime 倒序) */
|
||||
const list = ref<IFavoriteItem[]>([])
|
||||
|
||||
/** 过滤脏数据后的合法列表 */
|
||||
const validList = computed(() => list.value.filter(isValidFavoriteItem))
|
||||
|
||||
/** 按收藏时间倒序 */
|
||||
function sortByCreateTimeDesc(items: IFavoriteItem[]): IFavoriteItem[] {
|
||||
return [...items].sort((a, b) => b.createTime.localeCompare(a.createTime))
|
||||
}
|
||||
|
||||
/** 文章收藏(倒序) */
|
||||
const postItems = computed(() => sortByCreateTimeDesc(validList.value.filter(item => item.kind === 'post')))
|
||||
/** 瞬间收藏(倒序) */
|
||||
const momentItems = computed(() => sortByCreateTimeDesc(validList.value.filter(item => item.kind === 'moment')))
|
||||
/** 各类型数量 */
|
||||
const counts = computed(() => ({
|
||||
post: postItems.value.length,
|
||||
moment: momentItems.value.length,
|
||||
total: validList.value.length,
|
||||
}))
|
||||
|
||||
/** 是否已收藏 */
|
||||
function isFavorite(kind: FavoriteKind, id: string): boolean {
|
||||
const key = favoriteKey(kind, id)
|
||||
return validList.value.some(item => favoriteKey(item.kind, item.id) === key)
|
||||
}
|
||||
|
||||
/** 添加收藏(已存在则幂等忽略) */
|
||||
function add(item: IFavoriteItem): boolean {
|
||||
if (!isValidFavoriteItem(item) || isFavorite(item.kind, item.id))
|
||||
return false
|
||||
list.value.push(item)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 取消收藏,返回是否删除成功 */
|
||||
function remove(kind: FavoriteKind, id: string): boolean {
|
||||
const key = favoriteKey(kind, id)
|
||||
const index = list.value.findIndex(item => favoriteKey(item.kind, item.id) === key)
|
||||
if (index === -1)
|
||||
return false
|
||||
list.value.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 切换收藏,返回操作后是否处于收藏态 */
|
||||
function toggle(item: IFavoriteItem): boolean {
|
||||
if (isFavorite(item.kind, item.id))
|
||||
remove(item.kind, item.id)
|
||||
else
|
||||
add(item)
|
||||
return isFavorite(item.kind, item.id)
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
postItems,
|
||||
momentItems,
|
||||
counts,
|
||||
isFavorite,
|
||||
add,
|
||||
remove,
|
||||
toggle,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
},
|
||||
)
|
||||
@@ -16,6 +16,7 @@ setActivePinia(store)
|
||||
export default store
|
||||
|
||||
export * from './appConfig'
|
||||
export * from './favorites'
|
||||
export * from './halo'
|
||||
export * from './setting'
|
||||
// 模块统一导出
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 收藏快照:把文章(IPost)/瞬间(IMoment)组装为统一收藏项 IFavoriteItem
|
||||
* 纯函数层,快照自包含(纯文本摘要截断),供 store 与三处收藏按钮共用
|
||||
*/
|
||||
import type { IMoment, IPost } from '@/api/types/halo'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { extractPlainExcerpt } from '@/utils/text'
|
||||
|
||||
/** 收藏内容类型 */
|
||||
export type FavoriteKind = 'post' | 'moment'
|
||||
|
||||
/** 收藏项作者快照 */
|
||||
export interface IFavoriteOwner {
|
||||
/** post: owner.metadata.name;moment: owner.name */
|
||||
id?: string
|
||||
displayName: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
/** 统一收藏项(文章/瞬间,纯本地) */
|
||||
export interface IFavoriteItem {
|
||||
kind: FavoriteKind
|
||||
/** 详情 id(metadata.name) */
|
||||
id: string
|
||||
/** 封面(仅文章) */
|
||||
cover?: string
|
||||
/** 标题(仅文章) */
|
||||
title?: string
|
||||
/** 纯文本摘要:文章 excerpt/正文抽 120 字;瞬间正文抽 200 字 */
|
||||
content: string
|
||||
/** 收藏时刻(ISO 字符串) */
|
||||
createTime: string
|
||||
owner: IFavoriteOwner
|
||||
}
|
||||
|
||||
/** 文章正文/摘要截断字数 */
|
||||
const POST_EXCERPT_MAX = 120
|
||||
/** 瞬间正文截断字数 */
|
||||
const MOMENT_EXCERPT_MAX = 200
|
||||
|
||||
/** 取文章摘要文本(excerpt 优先,兜底从正文抽取) */
|
||||
function getPostExcerptText(post: IPost): string {
|
||||
return extractPlainExcerpt(post.spec.excerpt || post.content?.content || post.content?.raw, POST_EXCERPT_MAX)
|
||||
}
|
||||
|
||||
/** 文章 → 收藏快照 */
|
||||
export function buildPostFavoriteItem(post: IPost, now: Date = new Date()): IFavoriteItem {
|
||||
const owner = post.owner
|
||||
const cover = post.spec.cover ? checkImageUrl(post.spec.cover) : undefined
|
||||
return {
|
||||
kind: 'post',
|
||||
id: post.metadata.name,
|
||||
cover,
|
||||
title: post.spec.title,
|
||||
content: getPostExcerptText(post),
|
||||
createTime: now.toISOString(),
|
||||
owner: {
|
||||
id: owner.metadata?.name,
|
||||
displayName: owner.displayName || '',
|
||||
avatar: checkAvatarUrl(owner.avatar),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 瞬间 → 收藏快照(无封面/标题) */
|
||||
export function buildMomentFavoriteItem(moment: IMoment, now: Date = new Date()): IFavoriteItem {
|
||||
const owner = moment.owner
|
||||
// moment.owner 含 index signature,metadata 需显式收窄
|
||||
const ownerMeta = owner?.metadata as { name?: string } | undefined
|
||||
return {
|
||||
kind: 'moment',
|
||||
id: moment.metadata.name,
|
||||
content: extractPlainExcerpt(moment.spec.content?.html || moment.spec.content?.raw, MOMENT_EXCERPT_MAX),
|
||||
createTime: now.toISOString(),
|
||||
owner: {
|
||||
id: owner?.name || ownerMeta?.name,
|
||||
displayName: owner?.displayName || '',
|
||||
avatar: checkAvatarUrl(owner?.avatar),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 收藏唯一键(kind 与 id 拼接,两域 metadata.name 可能撞名) */
|
||||
export function favoriteKey(kind: FavoriteKind, id: string): string {
|
||||
return `${kind}:${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化读回数据为真边界数据,入库/展示前校验结构
|
||||
* 仅校验关键字段,非法项直接丢弃
|
||||
*/
|
||||
export function isValidFavoriteItem(item: unknown): item is IFavoriteItem {
|
||||
if (!item || typeof item !== 'object')
|
||||
return false
|
||||
const target = item as Partial<IFavoriteItem>
|
||||
const kindOk = target.kind === 'post' || target.kind === 'moment'
|
||||
const idOk = typeof target.id === 'string' && target.id !== ''
|
||||
const contentOk = typeof target.content === 'string'
|
||||
const createTimeOk = typeof target.createTime === 'string'
|
||||
const ownerOk = !!target.owner
|
||||
&& typeof target.owner === 'object'
|
||||
&& typeof (target.owner as IFavoriteOwner).displayName === 'string'
|
||||
return kindOk && idOk && contentOk && createTimeOk && ownerOk
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 文本工具:HTML 剥离为纯文本 + 摘要截断
|
||||
* 跨端实现(小程序无 DOM),仅用正则处理,不依赖 DOMParser
|
||||
*/
|
||||
|
||||
/** HTML 实体解码(覆盖常见实体即可) */
|
||||
const HTML_ENTITY_MAP: Record<string, string> = {
|
||||
' ': ' ',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': '\'',
|
||||
'&': '&',
|
||||
}
|
||||
|
||||
/** 行首 markdown 标记(# 标题 / * - 列表 / 数字序号 / 引用 > / 代码块 ``` 等) */
|
||||
const MD_PREFIX_REG = /^\s{0,3}(#{1,6}[ \t]|>|[+*-][ \t]|\d+[.、)][ \t]|```|~~~|!?\[)/gm
|
||||
|
||||
/**
|
||||
* 将 HTML/Markdown 源文本剥离为压缩空白后的纯文本
|
||||
* @param source 原文(可为空)
|
||||
*/
|
||||
export function htmlToPlainText(source?: string): string {
|
||||
if (!source)
|
||||
return ''
|
||||
let text = source
|
||||
// 剥离脚本/样式块
|
||||
text = text.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
||||
// 块级/换行标签替换为空格,其余标签整体剥离
|
||||
text = text
|
||||
.replace(/<\/(p|div|br|li|h[1-6]|blockquote|pre|tr|section|article)>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
// markdown 行首符号清理
|
||||
text = text.replace(MD_PREFIX_REG, '')
|
||||
// 实体解码
|
||||
text = text.replace(/&[a-z]+;|&#\d+;/gi, match => HTML_ENTITY_MAP[match.toLowerCase()] ?? ' ')
|
||||
// 压缩空白(含换行)
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** 截断文本,超长追加省略号 */
|
||||
export function truncateText(text: string, max: number, ellipsis = '…'): string {
|
||||
if (!text || text.length <= max)
|
||||
return text
|
||||
return `${text.slice(0, max).trimEnd()}${ellipsis}`
|
||||
}
|
||||
|
||||
/** 从 HTML/Markdown 源提取纯文本摘要(去标签 + 截断) */
|
||||
export function extractPlainExcerpt(source?: string, max = 120): string {
|
||||
return truncateText(htmlToPlainText(source), max)
|
||||
}
|
||||
Reference in New Issue
Block a user