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

refactor: 移除启动页逻辑,新增全局组件与偏好设置系统

1.  移除旧版启动页分流逻辑,直接跳转首页
2.  新增返回顶部、章节标题、导航栏等全局组件
3.  重构偏好设置系统,实现站点默认与本地差异分层管理
4.  删除冗余的测试mock、插件模块与样式文件
5.  优化文章卡片与评论组件样式,更新全局主题色
6.  清理废弃的请求参数与配置项
This commit is contained in:
小莫唐尼
2026-09-03 22:36:26 +08:00
parent cf9c02cfc3
commit 902faa70e7
49 changed files with 3170 additions and 4288 deletions
+1 -4
View File
@@ -28,10 +28,7 @@ defineExpose({
<template>
<view>
<!-- 这个先隐藏了知道这样用就行 -->
<view class="hidden text-center">
{{ helloKuRoot }}这里可以配置全局的东西
</view>
<uh-scroll-top />
<KuRootView />
+3 -1
View File
@@ -45,7 +45,7 @@ const COMMENT_WIDGET_CAPTCHA_COOKIES = 'comment-widget-captcha'
*/
export function getPostList(params: IPostListReq) {
return http.Get<IResponse<IPostListRes>>('/apis/api.content.halo.run/v1alpha1/posts', {
params,
query: params,
meta: { requestFrom: RequestFrom.Halo },
})
}
@@ -124,6 +124,7 @@ export function getPostByTagName(tagName: string, params: IPostListReq) {
export function getPostCommentList(params: ICommentListReq) {
return http.Get<IResponse<ICommentListRes>>('/apis/api.halo.run/v1alpha1/comments', {
params,
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo },
})
}
@@ -134,6 +135,7 @@ export function getPostCommentList(params: ICommentListReq) {
export function getPostCommentReplyList(commentName: string, params: ICommentListReq) {
return http.Get<IResponse<ICommentListRes>>(`/apis/api.halo.run/v1alpha1/comments/${commentName}/reply`, {
params,
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo },
})
}
+36 -5
View File
@@ -27,6 +27,13 @@ export interface IListResult<T> {
totalPages: number
}
export interface IOwner {
avatar?: string
displayName?: string
bio?: string
metadata:{name:string}
}
/* ---------- 文章 Post ---------- */
export interface IPostSpec {
@@ -34,6 +41,7 @@ export interface IPostSpec {
slug: string
excerpt?: string
cover?: string
owner:IOwner
/** 发布时间(Halo 2.x 结构,旧项目直接使用) */
publishTime?: string
deleted: boolean
@@ -83,6 +91,7 @@ export interface IContent {
export interface IPost {
metadata: IMetadata
spec: IPostSpec
owner: IOwner
status?: IPostStatus
content?: IContent
categories?: ICategory[]
@@ -270,13 +279,28 @@ export type ICommentListRes = IListResult<IComment>
export interface IMoment {
metadata: IMetadata
spec: {
content: string
owner: {
displayName: string
/** 作者(公开接口在顶层返回;spec.owner 只是作者用户名) */
owner?: {
avatar?: string
website?: string
bio?: string
displayName: string
name: string
[key: string]: unknown
}
spec: {
content: {
/** 正文 HTML */
html?: string
raw?: string
/** 多媒体(图片/视频/音频均在此,勿误读成 spec.medium) */
medium?: {
type?: 'PHOTO' | 'VIDEO' | 'AUDIO'
url?: string
[key: string]: unknown
}[]
[key: string]: unknown
}
owner?: string
visible: 'PUBLIC' | 'PRIVATE'
allowComment: boolean
approved: boolean
@@ -285,6 +309,13 @@ export interface IMoment {
tags?: string[]
releaseTime?: string
}
/** 互动数据(公开接口返回:点赞/评论数) */
stats?: {
approvedComment?: number
totalComment?: number
upvote?: number
[key: string]: unknown
}
status?: {
permalink: string
approved?: boolean
+9 -8
View File
@@ -77,10 +77,6 @@ export interface IPageConfig {
/** 审计模式配置 */
export interface IAuditConfig {
auditModeEnabled?: boolean
auditModeData?: {
jsonUrl?: string
jsonData?: string
}
}
/** 审核模式数据(公开接口 GET /audit-data 返回) */
@@ -119,6 +115,15 @@ export interface IAppConfig {
pluginConfig?: IPluginConfig
pageConfig?: IPageConfig
auditConfig?: IAuditConfig
/**
* 站点级展示偏好默认(L0,插件端 GeneralConfig.preferences 经 getConfigs additive 下发;
* 客户端 layout.home/cardType/isAvatarRadius 的站点默认来源,本地偏好可覆盖)
*/
preferences?: {
homeListLayout?: string
articleCardType?: string
avatarRadius?: boolean
}
[key: string]: unknown
}
@@ -139,10 +144,6 @@ export interface IQRCodeInfo {
[key: string]: unknown
}
export interface IQRCodeImg {
[key: string]: unknown
}
/** 检查更新结果(uhalo-upgrade 对接) */
export interface IUpdateCheckRes {
code: number
-9
View File
@@ -124,15 +124,6 @@ export function getQRCodeInfo(key: string) {
})
}
/**
* 获取文章二维码图片
*/
export function getQRCodeImg(postId: string) {
return http.Get<IResponse<unknown>>(`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/getQRCodeImg/${postId}`, {
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 恋爱模块 ==================== */
/**
@@ -1,12 +1,8 @@
<script lang="ts" setup>
/**
* 文章卡片(源自旧项目 components/article-card,新建复刻)
* 支持布局:左图右文(lr_image_text)/左文右图(lr_text_image)/上图下文(tb_image_text)/上文下图(tb_text_image)/仅文字(only_text)
*/
import { computed } from 'vue'
import dayjs from 'dayjs'
import { checkThumbnailUrl } from '@/utils/url'
import { useSettingStore } from '@/store/setting'
import { formatTime} from '@/utils/formatTime'
import type { IPost } from '@/api/types/halo'
const props = withDefaults(defineProps<{
@@ -39,7 +35,7 @@ const cardType = computed(() => {
/** 发布时间格式化 yyyy-MM-dd */
const publishTimeText = computed(() => {
const time = props.article.spec.publishTime
return time ? dayjs(time).format('YYYY-MM-DD') : ''
return time ? formatTime({d:time,f:'yyyy-MM-dd'}) : ''
})
/** 阅读数(兼容 status.stats.visits 与旧版顶层 stats.visit) */
@@ -53,21 +49,23 @@ function handleClick() {
</script>
<template>
<view class="uh-article-card mb-3 flex overflow-hidden rounded-xl bg-white p-2.5 shadow-sm" :class="cardType" @click="handleClick">
<view class="left">
<image class="thumbnail" :src="checkThumbnailUrl(article.spec.cover)" mode="aspectFill" lazy-load />
<view class="uh-global-card-glass overflow-hidden relative rounded-xl p-3" @click="handleClick">
<!-- v-if="article.spec.pinned" -->
<text class="absolute right-6 top-6 z-1 bg-secondary text-gray-60 text-xs px-2 py-1 rounded-lg"> 置顶 </text>
<image class="w-full h-36 rounded-lg" :src="checkThumbnailUrl(article.spec.cover)" mode="aspectFill" lazy-load />
<view class="flex flex-col w-full gap-y-2 text-sm">
<view class="mt-2 font-bold truncate">
{{ article.spec.title }}
</view>
<view class="right">
<view class="title">
<text v-if="article.spec.pinned" class="is-top">置顶</text>
<text class="title-text text-overflow">{{ article.spec.title }}</text>
</view>
<view class="content text-overflow-2">
<view class="content line-clamp-2 text-gray-600">
{{ article.status?.excerpt }}
</view>
<view class="foot">
<view class="create-time">
<text class="time-label">发布时间</text>
<view class="flex items-center justify-between text-xs text-gray-500">
<view class="flex items-center gap-x-1">
<image :src="article.owner.avatar" class="uh-global-card-glass rounded-full w-5 h-5" mode="aspectFill"></image>
<text>{{article.owner.displayName}}</text>
</view>
<view class="flex items-center gap-x-2">
{{ publishTimeText }}
</view>
<view class="visits">
@@ -79,261 +77,3 @@ function handleClick() {
</view>
</view>
</template>
<style scoped lang="scss">
.uh-article-card {
box-sizing: border-box;
margin: 0 24rpx;
&.h_row_col1 {
align-items: center;
}
&.home.h_row_col2 {
margin: 12rpx;
.left {
width: 100%;
height: 200rpx;
.thumbnail :deep(uni-image) {
border-radius: 6rpx 6rpx 0 0 !important;
}
}
.right {
.title {
display: flex;
align-items: center;
font-size: 26rpx;
font-weight: bold;
.is-top {
height: 36rpx;
margin-right: 10rpx;
line-height: 36rpx;
transform: scale(0.9);
}
}
.foot {
justify-content: space-between;
.create-time {
font-size: 24rpx;
.time-label {
display: none;
}
}
.visits {
font-size: 24rpx;
margin-left: 0;
}
}
}
&.tb_text_image {
padding: 12rpx;
.left .thumbnail :deep(uni-image) {
border-radius: 6rpx !important;
}
}
&.only_text {
padding: 24rpx;
.right .foot {
.create-time .time-label {
display: none;
}
.visits {
font-size: 24rpx;
}
}
}
}
&.lr_text_image {
.left {
order: 2;
padding-left: 30rpx;
}
.right {
order: 1;
padding-left: 0;
}
}
&.tb_image_text {
flex-direction: column;
padding: 24rpx;
.left {
width: 100%;
height: 340rpx;
.thumbnail :deep(uni-image) {
border-radius: 6rpx 6rpx 0 0 !important;
}
}
.right {
padding-left: 0;
padding: 24rpx 0 0;
width: 100%;
.foot {
justify-content: flex-start;
.create-time .time-label {
display: inline-block;
}
.visits {
margin-left: 24rpx;
}
}
}
}
&.tb_text_image {
flex-direction: column;
.left {
width: 100%;
height: 340rpx;
order: 2;
margin-top: 24rpx;
}
.right {
padding-left: 0;
width: 100%;
order: 1;
.foot {
justify-content: flex-start;
.create-time .time-label {
display: inline-block;
}
.visits {
margin-left: 24rpx;
}
}
}
}
&.only_text {
padding: 36rpx;
.left {
display: none;
}
.right {
padding-left: 0;
.content {
margin-top: 24rpx;
}
.foot {
justify-content: flex-start;
margin-top: 24rpx;
.create-time .time-label {
display: inline-block;
}
.visits {
margin-left: 24rpx;
}
}
}
}
.left {
width: 240rpx;
height: 180rpx;
.thumbnail {
width: 100%;
height: 100%;
border-radius: 12rpx;
}
}
.right {
width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
padding-left: 30rpx;
box-sizing: border-box;
.title {
display: flex;
align-items: center;
font-size: 30rpx;
.is-top {
height: 40rpx;
padding: 0 12rpx;
margin-right: 10rpx;
line-height: 40rpx;
font-size: 24rpx;
white-space: nowrap;
vertical-align: 4rpx;
color: #fff;
background-color: rgba(33, 150, 243, 1);
border-radius: 6rpx 12rpx;
}
&-text {
color: #303133;
}
}
.content {
display: -webkit-box;
font-size: 26rpx;
color: #909399;
height: 80rpx;
margin-top: 14rpx;
line-height: 42rpx;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.foot {
display: flex;
font-size: 24rpx;
justify-content: space-between;
align-items: center;
color: #909399;
margin-top: 18rpx;
.create-time {
font-size: 26rpx;
.time-label {
display: none;
}
}
.visits .number {
padding: 0 6rpx;
font-size: 26rpx;
}
}
}
}
</style>
@@ -1,54 +0,0 @@
<script lang="ts" setup>
/**
* 分类小卡片(源自旧项目 components/category-mini-card,新建复刻)
*/
import { computed } from 'vue'
import { checkThumbnailUrl } from '@/utils/url'
import type { ICategory } from '@/api/types/halo'
const props = defineProps<{
category: ICategory
}>()
const cover = computed(() => checkThumbnailUrl(props.category.spec.cover))
</script>
<template>
<view class="uh-category-mini-card relative inline-block h-[180rpx] w-[260rpx] overflow-hidden rounded-xl text-center text-white">
<image class="img" :src="cover" mode="aspectFill" lazy-load />
<view class="content absolute inset-0 z-3 flex flex-col items-center justify-center">
<view class="name text-[30rpx] font-bold">
{{ category.spec.displayName }}
</view>
<text class="label mt-1 text-[24rpx]"> {{ category.postCount ?? 0 }} </text>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-category-mini-card {
background-color: #fff;
box-shadow: 0 2rpx 24rpx rgb(0 0 0 / 3%);
&::before {
content: '';
position: absolute;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 25%);
backdrop-filter: blur(3rpx);
}
.img {
width: 100%;
height: 100%;
}
.name {
color: inherit;
}
}
</style>
@@ -1,7 +1,4 @@
<script lang="ts" setup>
/**
* 评论条目(源自旧项目 components/comment-item,新建复刻)
*/
import { computed } from 'vue'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -69,89 +66,30 @@ function handleOnDetail() {
</script>
<template>
<view
v-if="comment"
class="comment-item mt-7 box-border flex flex-col pt-6"
:class="{ 'child-comment-item': isChild, 'no-solid': !useSolid, ...classItem }"
>
<view class="comment-item-user flex items-center">
<image
class="user-avatar box-border h-[70rpx] w-[70rpx] shrink-0 border-4 border-white shadow-sm"
:class="{ 'is-radius': globalAppSettings.isAvatarRadius }"
:src="avatar"
mode="aspectFill"
@error="handleOnImageError"
/>
<view class="user-info w-0 flex-1 pl-7">
<view class="author text-[26rpx] text-[#606266]">
<text class="text-grey text-size-m">{{ comment.spec.owner.displayName }}</text>
<view v-if="comment" class="box-border flex pt-4" :class="{
'pl-10':props.isChild,
}">
<view class="flex shrink-0">
<image class="box-border h-10 w-10 shrink-0 rounded-full border border-white uh-shadow-xs border-solid" :src="avatar" mode="aspectFill"
@error="handleOnImageError" />
</view>
<view class="mt-1 flex">
<view class="time text-grey text-[22rpx] text-[#999]">
<text>{{ createTimeText }}</text>
<text class="ml-3">{{ createTimeAgo }}</text>
<view class="flex-1 box-border pl-2">
<view class="text-sm text-gray-500">
<text class="text-grey text-xs">{{ comment.spec.owner.displayName }}</text>
</view>
</view>
</view>
<view v-if="useActions" class="actions flex gap-4">
<view v-if="!disallowComment" class="action-btn px-1 py-0.5 text-[24rpx] text-blue" @click="handleOnReply">
<view class="mt-0.5 box-border text-sm text-gray-900 leading-5" @click="handleOnDetail"
v-html="comment.spec.raw" />
<view class="mt-2 flex items-center gap-x-4">
<text class="text-gray-900 text-xs">{{ createTimeText }}</text>
<view v-if="useActions" class="actions flex gap-2 font-bold">
<view v-if="!disallowComment" class="text-xs" @click="handleOnReply">
回复
</view>
<view class="action-btn text-grey px-1 py-0.5 text-[24rpx]" @click="handleOnCopy">
<view class="text-grey text-xs" @click="handleOnCopy">
复制
</view>
</view>
</view>
<view
class="comment-item-content ml-[98rpx] mt-3 box-border text-[28rpx] text-[#303133] leading-[1.8]"
:class="{ 'has-bg': useContentBg, 'not-ml': isChild }"
@click="handleOnDetail"
v-html="comment.spec.raw"
/>
</view>
</view>
</template>
<style scoped lang="scss">
.comment-item {
border-top: 2rpx solid #f5f5f5;
&.child-comment-item {
padding-top: 0;
margin-left: 80rpx;
border: 0;
}
&.no-solid {
border: 0;
margin-top: 0 !important;
}
.comment-item-user {
.user-avatar {
border-radius: 12rpx;
&.is-radius {
border-radius: 50%;
}
}
.user-info {
.time {
margin-top: 8rpx;
}
}
}
.comment-item-content {
&.has-bg {
background-color: #fafafa;
padding: 6rpx 24rpx;
}
&.not-ml {
margin-left: 98rpx;
}
}
}
</style>
@@ -1,10 +1,7 @@
<script lang="ts" setup>
/**
* 评论列表(源自旧项目 components/comment-list,新建复刻)
* 支持一级/二级评论展示、刷新、回复入口
*/
import { ref } from 'vue'
import { getPostCommentList } from '@/api/halo'
import { checkAvatarUrl } from '@/utils/url'
import type { IComment, ICommentListRes } from '@/api/types/halo'
const props = withDefaults(defineProps<{
@@ -40,7 +37,11 @@ async function handleGetData() {
try {
const res = await getPostCommentList({ ...queryParams.value })
result.value = res.data
dataList.value = res.data.items
dataList.value = res.data.items.map((item) => {
// todo:临时
item.spec.owner.avatar = checkAvatarUrl(item.spec.owner.avatar??'https://api.dicebear.com/10.x/adventurer-neutral/svg')
return item
})
loading.value = 'success'
emit('on-loaded', dataList.value)
}
@@ -94,22 +95,24 @@ handleGetData()
</script>
<template>
<view class="uh-comment-list">
<view class="w-full box-border px-2">
<view class="uh-global-card-glass box-border uh-shadow-xs rounded-xl p-3">
<!-- 顶部区域 -->
<view class="comment-list-head relative box-border pl-6 text-[30rpx] font-bold">
<view class="title">
<text>评论列表</text>
<text class="count text-[28rpx] font-normal">{{ result?.total || 0 }}</text>
</view>
<view class="refresh flex items-center gap-1.5 text-[26rpx] text-[#666] font-normal" @click="handleGetData">
<wd-icon name="refresh" size="14px" color="#909399" />
<text class="refresh-text ml-1.5">刷新</text>
</view>
<uh-section-title>
评论列表
<template #right>
<view class="flex items-center gap-1.5 text-xs text-gray-500 font-normal"
@click="handleGetData">
<wd-icon name="refresh" size="28rpx" />
<text class="">刷新</text>
</view>
</template>
</uh-section-title>
<!-- 内容区域 -->
<view class="comment-list-content mt-6 pb-9">
<view v-if="loading !== 'success'" class="loading-wrap h-[506rpx] w-full flex items-center justify-center">
<view class="mt-2">
<view v-if="loading !== 'success'"
class="loading-wrap h-[506rpx] w-full flex items-center justify-center">
<view v-if="loading === 'loading'" class="loading flex flex-col items-center justify-center">
<view class="loading-text text-[26rpx] text-[#999]">
加载中请稍等...
@@ -117,90 +120,57 @@ handleGetData()
</view>
<view v-else-if="loading === 'error'" class="error flex flex-col items-center">
<text class="text-grey">加载失败</text>
<wd-button v-if="!disallowComment" size="small" plain type="primary" class="mt-2" @click="handleGetData">
<wd-button v-if="!disallowComment" size="small" plain type="primary" class="mt-2"
@click="handleGetData">
刷新试试
</wd-button>
</view>
</view>
<block v-else>
<view v-if="disallowComment && dataList.length !== 0" class="disallow-tip rounded-xl bg-[#fef0f0] px-3 py-2 text-[26rpx] text-[#f56c6c]">
<view v-if="disallowComment && dataList.length !== 0"
class="rounded-xl bg-gray-50 px-3 py-2 text-xs text-red-400">
ԾԾ 博主已设置该文章禁止评论!
</view>
<view v-if="dataList.length === 0" class="empty pt-5">
<view v-if="dataList.length === 0" class="empty py-12">
<view class="empty-box flex flex-col items-center">
<wd-empty :description="disallowComment ? '暂无评论' : '暂无评论'" />
<view v-if="disallowComment" class="empty-tip mt-1.5 text-[24rpx] text-[#f56c6c]">
- 文章已开启禁止评论 -
<wd-empty>
<template #image>
<wd-icon name="empty" size="100rpx" class="text-primary" />
</template>
<template #bottom>
<text class="mt-2 text-sm text-gray-500">{{disallowComment ? '暂无评论' : '暂无评论'}}</text>
<view v-if="disallowComment" class="mt-2 text-xs text-red-400">
文章已开启禁止评论
</view>
<wd-button v-else size="small" plain type="primary" class="mt-2" @click="handleToComment()">
<view v-else class="mt-2 bg-primary text-black text-sm px-4 py-1.5 rounded-lg" @click="handleToComment()">
抢沙发
</wd-button>
</view>
</template>
</wd-empty>
</view>
</view>
<block v-else>
<!-- 一级评论 -->
<template v-for="comment in dataList" :key="comment.metadata.name">
<uh-comment-item
:use-content-bg="false"
:is-child="false"
:comment="comment"
:post-name="postName"
:disallow-comment="disallowComment"
@on-copy="handleCopyContent"
@on-comment="handleToComment"
@on-detail="handleShowCommentDetail"
/>
<uh-comment-item :use-content-bg="false" :is-child="false" :comment="comment"
:post-name="postName" :disallow-comment="disallowComment" @on-copy="handleCopyContent"
@on-comment="handleToComment" @on-detail="handleShowCommentDetail" />
<!-- 二级评论 -->
<template v-if="comment.replies && comment.replies.items.length !== 0">
<uh-comment-item
v-for="childComment in comment.replies.items"
:key="childComment.metadata.name"
:use-content-bg="false"
:is-child="true"
:comment="childComment"
:post-name="postName"
:disallow-comment="disallowComment"
@on-copy="handleCopyContent"
@on-comment="handleToComment"
@on-detail="handleShowCommentDetail"
/>
<uh-comment-item v-for="childComment in comment.replies.items"
:key="childComment.metadata.name" :use-content-bg="false" :is-child="true"
:comment="childComment" :post-name="postName" :disallow-comment="disallowComment"
@on-copy="handleCopyContent" @on-comment="handleToComment"
@on-detail="handleShowCommentDetail" />
</template>
</template>
</block>
</block>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-comment-list {
.comment-list-head {
display: flex;
align-items: center;
justify-content: space-between;
&::before {
content: '';
position: absolute;
left: 0;
top: 8rpx;
width: 8rpx;
height: 26rpx;
background-color: rgb(3 174 252);
border-radius: 6rpx;
}
}
.comment-list-content {
.loading-wrap {
.loading-text {
/* 无额外样式 */
}
}
}
}
</style>
@@ -0,0 +1,269 @@
<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>
@@ -0,0 +1,132 @@
<script lang="ts" setup>
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 { formatTime } from '@/utils/formatTime'
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 appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const bannerConfig = computed(() => haloConfigs.value.pageConfig?.homeConfig?.bannerConfig)
/* ---------------- 数据(高内聚:内部请求公开接口) ---------------- */
const bannerList = ref<IBannerItem[]>([])
const currentIndex = ref(0)
const currentBanner = ref<IBannerItem | null>(null)
/** 公开 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: formatTime({
d: item.date,
f: 'yyyy年MM月dd日 星期w'
}),
authorName: item.authorName,
authorAvatar: item.authorAvatar ? checkAvatarUrl(item.authorAvatar) : '',
}))
}
onMounted(async () => {
try {
const res = await getBanners()
bannerList.value = mapBanners(res.data || [])
handleBannerChange({
detail: { current: 0 }
})
}
catch (err) {
console.error('获取轮播图失败', err)
}
})
function handleBannerChange(e : any) {
currentIndex.value = e?.detail?.current ?? 0
currentBanner.value = bannerList.value[currentIndex.value]
}
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',
})
}
function handleToSearch() {
uni.navigateTo({ url: '/pages-blog/search/search' })
}
</script>
<template>
<view v-if="bannerList.length > 0" class="relative w-full mb-6 box-border ">
<view class="box-border relative w-full h-52 overflow-hidden">
<swiper class="w-full h-52" :circular="true" :indicator-dots="false" :autoplay="true" :interval="3000"
:duration="1000" @change="handleBannerChange">
<swiper-item v-for="(item, index) in bannerList" :key="index" class="relative">
<image :src="item.image || item.src" class="h-full w-full" mode="aspectFill"
@click.stop="handleOnClick(item)" />
</swiper-item>
</swiper>
<view v-if="currentBanner"
class="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-y-2 bg-white/5 backdrop-blur-[2rpx]">
<text class="text-sm text-gray-900 font-bold bg-secondary px-3 py-0.5 rounded-xl">
{{ currentBanner.title }}
</text>
<text
class="text-xs text-white text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">{{ currentBanner.date }}</text>
</view>
</view>
<view class="absolute bottom-0 left-0 right-0 h-12 w-full bg-gradient-to-b from-white/0 to-page" />
<view class="absolute left-0 right-0 z-10 flex items-center justify-center -translate-y-6">
<view
class="uh-global-card-glass border w-4/5 rounded-full px-4 py-2 text-sm text-gray-600 flex items-center justify-center gap-x-2"
@click="handleToSearch()">
<wd-icon name="search-line" size="32rpx"></wd-icon>
<text>哈喽想看些什么 <text class="bg-secondary rounded-xl px-1">{ 内容 }</text> ~</text>
</view>
</view>
</view>
</template>
@@ -0,0 +1,102 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { getCategoryList } from '@/api/halo'
import { checkThumbnailUrl } from '@/utils/url'
import { useAppConfigStore } from '@/store/appConfig'
import type { ICategory } from '@/api/types/halo'
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const loading = ref<'loading' | 'success' | 'error'>('loading')
const categoryList = ref<ICategory[]>([])
const calcIsShowCategory = computed(() => {
if (calcAuditModeEnabled.value) {
return false;
}
return !!haloConfigs.value.pageConfig?.homeConfig?.useCategory
})
/** 精选分类 */
async function handleGetCategoryList() {
if (calcAuditModeEnabled.value || !calcIsShowCategory.value) {
loading.value = 'success'
return
}
try {
loading.value = 'loading'
const res = await getCategoryList({ fieldSelector: ['spec.hideFromList=false'], size: 3 })
categoryList.value = res.data.items
.map(item => {
item.spec.cover = checkThumbnailUrl(item.spec.cover)
return {
...item,
postCount: item.postCount ?? 0
}
})
.sort((a, b) => (b.postCount || 0) - (a.postCount || 0))
loading.value = 'success'
}
catch (err) {
console.error('获取分类失败', err)
loading.value = 'error'
}
}
function handleToCategoryPage() {
uni.switchTab({ url: '/pages/tabbar/category/category' })
}
function handleToCategoryBy(category : ICategory) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
onMounted(() => {
handleGetCategoryList()
})
</script>
<template>
<view v-if="calcIsShowCategory" class="mb-6">
<uh-section-title class="mb-4 px-3 box-border">
精选分类
<template #right>
<view class="flex items-center justify-center rounded-md bg-white p-1.5 text-gray-400" @click="handleToCategoryPage">
<wd-icon name="arrow-right" size="12px" />
</view>
</template>
</uh-section-title>
<view class="w-full grid grid-cols-2 grid-rows-auto h-42 box-border px-3 gap-2">
<view v-if="categoryList.length === 0"
class="cate-empty text-grey w-full flex items-center justify-center">
还没有任何分类~
</view>
<block v-else>
<view v-for="(category,index) in categoryList" :key="category.metadata.name"
class="uh-global-card-glass relative w-full h-full overflow-hidden rounded-xl text-center text-white"
:class="{'grid-row-span-2':index===0 }" @click="handleToCategoryBy(category)">
<image :src="category.spec.cover" class="w-full h-full" mode="aspectFill" lazy-load />
<view
class="absolute bottom-0 left-0 h-[140rpx] w-full bg-gradient-to-b from-black/0 to-black/30" />
<view class="absolute left-2 bottom-2 flex z-2 flex-col text-left">
<text class="text-sm font-bold">
{{ category.spec.displayName }}
</text>
<text class="mt-1 text-xs text-gray-200"> {{ category.postCount ?? 0 }} </text>
</view>
</view>
</block>
</view>
</view>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useAppConfigStore } from '@/store/appConfig'
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcIsShowQuickNavigationEnabled = computed(() => haloConfigs.value.pageConfig?.homeConfig?.useQuickNavigation)
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
/** 快捷导航列表(由配置控制显隐) */
const navList = computed(() => {
const loveEnabled = !!(haloConfigs.value.loveConfig as { loveEnabled ?: boolean })?.loveEnabled
const socialEnabled = !!(haloConfigs.value.authorConfig?.social as { enabled ?: boolean } | undefined)?.enabled
return [
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
bgColor: 'rgba(3, 169, 244, 0.95)',
icon: 'news',
path: '/pages-blog/archives/archives',
show: true,
},
{
key: 'vote',
title: '投票中心',
bgColor: 'rgba(0, 188, 212, 0.95)',
icon: 'box',
path: '/pages-blog/votes/votes',
// show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
show: true,
},
{
key: 'disclaimers',
title: '友情链接',
bgColor: 'rgba(0, 150, 136, 0.95)',
icon: 'link',
path: '/pages-blog/friend-links/friend-links',
// show: calcLinksPluginEnabled.value,
show: true,
},
{
key: 'love',
title: '恋爱日记',
bgColor: 'rgba(255, 76, 103, 0.95)',
icon: 'heart',
path: '/pages-blog/love/love',
// show: loveEnabled,
show: true,
},
{
key: 'contact-blogger',
title: '联系博主',
bgColor: 'rgba(255, 152, 0, 0.95)',
icon: 'message',
path: '/pages-blog/contact/contact',
show: socialEnabled,
},
].filter(item => item.show)
})
function handleClickNav(item : { path : string }) {
uni.navigateTo({ url: item.path })
}
</script>
<template>
<view v-if="navList.length" class="overflow-hidden rounded-xl p-3 px-4 mb-3">
<uh-section-title class="mb-4">
快捷导航
</uh-section-title>
<view class="grid grid-cols-5 gap-4">
<view v-for="item in navList" :key="item.key" class="flex flex-col items-center gap-2"
@click="handleClickNav(item)">
<view class="uh-global-card-glass border h-12 w-12 flex items-center justify-center rounded-2xl"
:style="{ backgroundColor: item.bgColor }">
<wd-icon :name="item.icon" size="24px" color="#fff" />
</view>
<view class="text-xs text-gray-900 font-bold">
{{ item.title }}
</view>
</view>
</view>
</view>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { onPageScroll } from '@dcloudio/uni-app'
import { ref, computed, useSlots, onMounted } from 'vue'
interface IProps {
useBack : boolean;
useTitle : boolean;
defaultTitle ?: string;
scrollTitle ?: string;
}
const props = withDefaults(defineProps<IProps>(), {
useBack: true,
useTitle: true,
})
const slots = useSlots()
const scrollY = ref(0)
const maxAlpha = ref(0.65)
const customStyle = computed(() => {
const alpha = Math.min(scrollY.value / 360, maxAlpha.value)
return {
backgroundColor: `rgba(255, 255, 255, ${alpha})`,
}
})
const scrollThreshold = computed(() => {
return scrollY.value / 360 <= 0.5;
})
const customCalss = computed(() => {
const _class = []
if (scrollThreshold.value) {
_class.push('text-white')
}
else {
_class.push('text-gray-900')
}
return _class;
})
const visibleTitle = computed(() => {
if (!props.scrollTitle) {
return props.defaultTitle;
}
if (scrollThreshold.value) {
return props.defaultTitle;
}
return props.scrollTitle;
})
// todo:注意:如果是从分享进来的,我们需要处理为返回 home页面
function handleBack(){
uni.navigateBack({ delta: 1 })
}
onPageScroll((e : any) => {
scrollY.value = e.scrollTop
})
</script>
<template>
<view class="box-border pt-safe w-full fixed left-0 top-0 z-50" :class="customCalss" :style="[customStyle]">
<view class="w-full h-[46px] flex items-center gap-x-4 box-border px-3 backdrop-blur-[2rpx]">
<!-- 左边 -->
<view class="shrink-0" @click="handleBack()">
<view
class="uh-global-card-glass h-7 px-3 rounded-full border flex items-center gap-x-2 text-gray-900 text-sm">
<wd-icon name="arrow-left" size="32rpx"></wd-icon>
<view class="w-[1px] h-4 bg-white/60" />
<text class="text-xs font-bold">返回</text>
</view>
</view>
<!-- 中间 -->
<view class="flex-1 truncate text-center font-bold transition-colors duration-300">
<slot> {{visibleTitle}} </slot>
</view>
<!-- 右边 -->
<view class="shrink-0 min-w-18">
<slot name="right"></slot>
</view>
</view>
</view>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
interface IProps {
customClass : Array<string>;
}
const props = defineProps({
customClass: () => {
return []
},
})
function handleScrollTop() {
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
}
</script>
<template>
<view
class="uh-global-card-glass border fixed bottom-24 right-4 z-90 h-10 w-10 flex items-center justify-center rounded-full text-primary"
:class="props.customClass" @click="handleScrollTop">
<wd-icon name="arrow-up" size="20px" />
</view>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useSlots } from 'vue'
const slots = useSlots();
const hasRightSlot = computed(() => !!slots.right?.())
</script>
<template>
<view class="w-full flex items-center justify-between">
<view class="shrink-0 relative">
<text class="font-bold relative z-2">
<slot></slot>
</text>
<view class="absolute z-1 -right-1 -bottom-0.5 rounded-xl bg-secondary h-4 w-4/5"></view>
</view>
<view v-if="hasRightSlot" class="flex-1 flex items-center justify-end">
<slot name="right"></slot>
</view>
</view>
</template>
-421
View File
@@ -1,421 +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 { 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 emit = defineEmits<{
(e: 'on-click', item: IBannerItem): void
(e: 'on-more'): void
(e: 'change', event: { current: number }): void
}>()
/* ---------------- 状态 ---------------- */
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
emit('change', event)
}
}
/** 手动点击了指示器[小图模式] */
function swiperIndTap(index: number) {
const event = { current: index }
currentIndex.value = index
emit('change', event)
}
function handleOnClick(item: IBannerItem) {
emit('on-click', item)
}
</script>
<template>
<view v-if="displayItems.length > 0" class="uh-e-swiper">
<view class="swiper-box" :class="[dotPosition]">
<swiper
class="swiper"
: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" class="swiper-mfw-item">
<image
:src="item.image || item.src"
class="image h-full w-full"
mode="aspectFill"
@click.stop="handleOnClick(item)"
/>
</swiper-item>
</swiper>
<!-- 指示器 [Top 日期角标]:显示当前条目 date(//) -->
<view v-if="useTop && dateParts" class="indicator-box indicator-top-box">
<view class="top-date-hot">
<view class="left-date-ri">
<text class="date-ri-text">{{ dateParts.day }}</text>
</view>
<view class="center-date-nianyue">
<view class="left-width-bgcolor" />
<view class="right-date-nianyue">
<text class="top-yue-usa">{{ dateParts.monthEn }}</text>
<text class="bottom-nian">{{ dateParts.year }}</text>
</view>
</view>
<view class="right-hot-ttf">
<text class="hot-text text-overflow-2">{{ title }}</text>
</view>
</view>
</view>
<!-- 指示器 标题区域 + 作者/日期信息(useUser) -->
<view v-if="useTitle" class="indicator-top" :class="{ 'no-dot': !useDot }">
<view v-if="useUser && (currentItem.authorName || authorDateText)" class="author-line">
<view v-if="currentItem.authorAvatar" class="author-avatar">
<image :src="currentItem.authorAvatar" class="h-full w-full" mode="aspectFill" />
</view>
<text class="author-name">{{ currentItem.authorName }}</text>
<text v-if="authorDateText" class="author-date">{{ authorDateText }}</text>
</view>
<view v-if="currentTitle" class="top-title">
<text class="title-text text-overflow-2">{{ currentTitle }}</text>
</view>
</view>
<!-- 指示器 [左边图片列表] -->
<view v-if="useDot" class="indicator-bottom">
<view class="bottom-left-imagelist">
<view
v-for="(item, index) in displayItems"
:key="index"
class="bottom-item"
:class="currentIndex === index ? 'current' : 'no'"
@click="swiperIndTap(index)"
>
<image :src="item.image || item.src" class="image h-full w-full" mode="aspectFill" />
</view>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-e-swiper {
position: relative;
width: 100%;
overflow: hidden;
.swiper-box {
position: relative;
width: 100%;
.swiper {
width: 100%;
border-radius: 12rpx;
}
}
/* 日期角标(顶部) */
.indicator-top-box {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 5;
padding: 16rpx 24rpx;
.top-date-hot {
display: flex;
align-items: center;
.left-date-ri {
.date-ri-text {
font-size: 40rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 2rpx 8rpx rgb(0 0 0 / 40%);
}
}
.center-date-nianyue {
display: flex;
align-items: center;
margin-left: 12rpx;
.left-width-bgcolor {
width: 2rpx;
height: 40rpx;
background-color: rgb(255 255 255 / 50%);
margin-right: 12rpx;
}
.right-date-nianyue {
display: flex;
flex-direction: column;
.top-yue-usa {
font-size: 20rpx;
color: #fff;
text-shadow: 0 2rpx 8rpx rgb(0 0 0 / 40%);
}
.bottom-nian {
font-size: 16rpx;
color: rgb(255 255 255 / 80%);
}
}
}
.right-hot-ttf {
flex: 1;
margin-left: 20rpx;
.hot-text {
display: block;
font-size: 24rpx;
color: #fff;
text-shadow: 0 2rpx 8rpx rgb(0 0 0 / 40%);
}
}
}
}
/* 底部标题/作者浮层 */
.indicator-top {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
padding: 48rpx 24rpx 20rpx;
background: linear-gradient(to top, rgb(0 0 0 / 45%), transparent);
.author-line {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 8rpx;
.author-avatar {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
overflow: hidden;
border: 1rpx solid rgb(255 255 255 / 60%);
}
.author-name {
font-size: 22rpx;
color: rgb(255 255 255 / 92%);
text-shadow: 0 1rpx 4rpx rgb(0 0 0 / 40%);
}
.author-date {
font-size: 20rpx;
color: rgb(255 255 255 / 70%);
text-shadow: 0 1rpx 4rpx rgb(0 0 0 / 40%);
}
}
.top-title {
.title-text {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 2rpx 8rpx rgb(0 0 0 / 40%);
}
}
}
/* 底部小图指示器 */
.indicator-bottom {
position: absolute;
left: 0;
right: 0;
bottom: 16rpx;
z-index: 5;
padding: 0 24rpx;
display: flex;
justify-content: flex-end;
.bottom-left-imagelist {
display: flex;
gap: 12rpx;
.bottom-item {
width: 96rpx;
height: 64rpx;
border-radius: 8rpx;
overflow: hidden;
opacity: 0.6;
border: 2rpx solid transparent;
&.current {
opacity: 1;
border-color: #fff;
}
}
}
}
}
</style>
-4
View File
@@ -45,9 +45,5 @@ export const DefaultAppConfigs: IAppConfig = {
},
auditConfig: {
auditModeEnabled: false,
auditModeData: {
jsonUrl: '',
jsonData: '',
},
},
}
-13
View File
@@ -3,8 +3,6 @@
*/
export interface IAppSettings {
/** 是否每次启动都显示启动页 */
showStartPage: boolean
/** 评论头像是否圆形 */
isAvatarRadius: boolean
banner: {
@@ -25,12 +23,6 @@ export interface IAppSettings {
/** 是否屏蔽广告 */
disabled: boolean
}
/** 评论弹幕(文章详情) */
barrage: {
use: boolean
/** 弹幕位置(rightToLeft / leftBottom) */
type: string
}
gallery: {
/** 是否使用瀑布流 */
useWaterfull: boolean
@@ -55,7 +47,6 @@ export interface IAppSettings {
}
export const DefaultAppSettings: IAppSettings = {
showStartPage: false,
isAvatarRadius: false,
banner: {
useDot: true,
@@ -69,10 +60,6 @@ export const DefaultAppSettings: IAppSettings = {
timeout: 3,
disabled: false,
},
barrage: {
use: false,
type: 'leftBottom',
},
gallery: {
useWaterfull: true,
},
+14 -9
View File
@@ -5,6 +5,8 @@
*/
import { checkImageUrl } from '@/utils/url'
const primaryColor = '#B9E424'
export const markdownConfig = {
/** 图片域名前缀(mp-html 相对路径补全,源自旧配置 domain: BASE_API) */
domain: import.meta.env.VITE_SERVER_BASEURL || '',
@@ -34,52 +36,55 @@ export const markdownConfig = {
blockquote: `
padding: 8px 15px;
color: #606266;
background: #f2f6fc;
border-left: 5px solid #50bfff;
background-color: rgb(255 255 255 / 55%);
backdrop-filter: blur(24rpx) saturate(160%);
-webkit-backdrop-filter: blur(24rpx) saturate(160%);
border-left: 5px solid ${primaryColor};
border-radius: 4px;
line-height: 26px;
margin-bottom: 18px;
box-shadow: 0 0px 12px rgba(0, 0, 0, 0.035);
`,
ul: 'padding-left: 15px;line-height: 1.85;',
ol: 'padding-left: 15px;line-height: 1.85;',
li: 'margin-bottom: 12px;line-height: 1.85;',
h1: `
margin: 30px 0 20px;
color: var(--main);
color: ${primaryColor};
line-height: 24px;
position: relative;
font-size:1.2em;
`,
h2: `
color: var(--main);
color: ${primaryColor};
line-height: 24px;
position: relative;
margin: 22px 0 16px;
font-size: 1.16em;
`,
h3: `
color: var(--main);
color: ${primaryColor};
line-height: 24px;
position: relative;
margin: 26px 0 18px;
font-size: 1.14em;
`,
h4: `
color: var(--main);
color: ${primaryColor};
line-height: 24px;
margin-bottom: 18px;
position: relative;
font-size: 1.12em;
`,
h5: `
color: var(--main);
color: ${primaryColor};
line-height: 24px;
margin-bottom: 14px;
position: relative;
font-size: 1.1em;
`,
h6: `
color: #303133;
color: ${primaryColor};
line-height: 24px;
margin-bottom: 14px;
position: relative;
@@ -95,7 +100,7 @@ export const markdownConfig = {
video: 'width: 100%',
},
/** 容器样式 */
containStyle: 'font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;padding:12px;font-size: 14px;color: #606266;word-spacing: 0.8px;letter-spacing: 0.8px;border-radius: 6px;background-color:#FFFFFF;',
containStyle: 'font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;padding:12px;font-size: 14px;color: #606266;word-spacing: 0.8px;letter-spacing: 0.8px;border-radius: 0px;background-color:transparent;',
/** 加载图 / 空图(来自应用配置 imagesConfig) */
loadingGif: checkImageUrl(undefined),
emptyGif: checkImageUrl(undefined),
+2 -2
View File
@@ -5,8 +5,8 @@ export enum DataLoadingStatusEnum {
Success = 'success',
}
export function useDataLoadingStatus() {
const loadingStatus = ref<DataLoadingStatusEnum>(DataLoadingStatusEnum.Loading)
export function useDataLoadingStatus(status?:DataLoadingStatusEnum) {
const loadingStatus = ref<DataLoadingStatusEnum>(status??DataLoadingStatusEnum.Loading)
function resetLoadingStatus() {
loadingStatus.value = DataLoadingStatusEnum.Loading
-1
View File
@@ -2,7 +2,6 @@ import type { CustomRequestOptions } from '@/http/types';
import { useTokenStore } from '@/store';
import { getEnvBaseUrl } from '@/utils';
import { stringifyQuery } from './tools/queryString';
import qs from 'qs';
// 请求基准地址
const baseUrl = getEnvBaseUrl();
-1
View File
@@ -3,7 +3,6 @@
*/
export type CustomRequestOptions = UniApp.RequestOptions & {
query?: Record<string, any>
params?: Record<string, any>
/** 出错时是否隐藏错误提示 */
hideErrorToast?: boolean
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
+118 -232
View File
@@ -3,21 +3,22 @@ import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getPostByName, getPostCommentReplyList, postTrackersCounter, submitUpvote } from '@/api/halo'
import { createVerificationCode, requestRestrictReadCheck } from '@/api/uni-halo'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { RestrictReadType } from '@/api/types/uni-halo'
import { formatTime } from '@/utils/formatTime'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl, checkIsUrl } from '@/utils/url'
import { checkPostRestrictRead, copyToClipboard, getRestrictReadTypeName, getShowableContent } from '@/utils/restrictRead'
import { getDomainOnly } from '@/utils/urlParams'
import { markdownConfig } from '@/config/markdown'
import type { IComment, IPost } from '@/api/types/halo'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { IComment, IPost } from '@/api/types/halo'
import type { RestrictReadType } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '内容详情',
enablePullDownRefresh: true,
navigationStyle: 'custom'
},
})
@@ -72,20 +73,10 @@ const bloggerInfo = computed(() => {
})
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcIsShowComment = computed(() => !!postDetailConfig.value?.showComment)
const doubanPluginConfig = computed(() => (haloConfigs.value.pluginConfig?.doubanPlugin as { position ?: string } | undefined) || {})
/** 原文链接(annotation 配置) */
const originalURL = computed(() => result.value?.metadata.annotations?.unihalo_originalURL || '')
/* ---------------- 工具 ---------------- */
function calcUrl(url: string): string {
if (checkIsUrl(url))
return url
return import.meta.env.VITE_SERVER_BASEURL + url
}
/** 从 HTML 提取投票块 id */
function extractVoteBlockIds(html : string) : string[] {
@@ -139,7 +130,8 @@ async function handleGetData() {
if (tempResult) {
tempResult._voteIds = extractVoteBlockIds(res.data.content?.raw || res.data.content?.content || '')
tempResult._doubanUrls = extractDoubanBlockUrls(res.data.content?.raw || res.data.content?.content || '')
tempResult.owner.avatar = checkAvatarUrl(tempResult.owner.avatar)
tempResult.spec.cover = checkImageUrl(tempResult.spec.cover)
const openid = uni.getStorageSync('openid')
if (openid === '' || openid === null) {
handleGetOpenid()
@@ -201,8 +193,9 @@ function hasUpvoted(): boolean {
}
async function handleDoLikes() {
if (!result.value)
if (!result.value) {
return
}
if (hasUpvoted()) {
uni.showToast({ icon: 'none', title: '已经点过赞啦!' })
return
@@ -256,8 +249,9 @@ function readMore() {
/** 校验密码/验证码 */
async function restrictReadCheck() {
if (!result.value)
if (!result.value) {
return
}
if (!restrictReadInputCode.value) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
@@ -401,15 +395,6 @@ function handleToOriginal(originalURLValue: string) {
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function handlePreview(index : number, list : { url : string }[]) {
uni.previewImage({
@@ -421,7 +406,7 @@ function handlePreview(index: number, list: { url: string }[]) {
/* ---------------- 格式化 ---------------- */
function formatPublishTime(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' }) : ''
}
/* ---------------- 生命周期 ---------------- */
@@ -436,7 +421,7 @@ onPullDownRefresh(() => {
})
onShareAppMessage(() => {
const cover = result.value?.spec.cover ? calcUrl(result.value.spec.cover) : ''
const cover = result.value?.spec.cover ? checkImageUrl(result.value.spec.cover) : ''
return {
path: `/pages-blog/article-detail/article-detail?name=${result.value?.metadata.name}`,
title: result.value?.spec.title || '',
@@ -445,7 +430,7 @@ onShareAppMessage(() => {
})
onShareTimeline(() => {
const cover = result.value?.spec.cover ? calcUrl(result.value.spec.cover) : ''
const cover = result.value?.spec.cover ? checkImageUrl(result.value.spec.cover) : ''
return {
title: result.value?.spec.title || '',
query: result.value ? `name=${result.value.metadata.name}` : '',
@@ -453,288 +438,189 @@ onShareTimeline(() => {
}
})
watch(haloConfigs, () => {
// 配置就绪后触发
}, { deep: true })
const globalAppSettings = computed(() => settingStore.settings)
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-safe" style="background-color: #fafafd;">
<view class="bg-page box-border min-h-screen w-screen flex flex-col pb-safe">
<!-- 顶部导航 -->
<uh-navbar default-title="内容详情" :scroll-title="result?.spec?.title" />
<!-- 骨架屏 -->
<view v-if="loadingStatus !== 'success'" class="box-border p-4">
<uh-data-loading :loading-status="loadingStatus" @refresh="handleGetData()" />
</view>
<block v-else>
<view v-else class="pt-72 box-border">
<!-- 顶部背景封面区域-->
<view class="fixed left-0 top-0 w-full h-72">
<image v-if="result?.spec.cover" :src="result.spec.cover" class="w-full h-full" mode="aspectFill" />
<view class="absolute bottom-0 left-0 h-[140rpx] w-full bg-gradient-to-b from-white/0 to-page" />
</view>
<view
class="box-border rounded-lt-3xl rounded-rt-3xl -translate-y-12 uh-global-card-glass border border-b-none overflow-hidden"
:style="{
boxShadow: '0 -16rpx 12rpx rgba(0, 0, 0, 0.035)',
}">
<!-- 顶部信息 -->
<view class="head flex flex-col items-center rounded-xl bg-white p-4 shadow-sm">
<view class="title text-center text-[36rpx] font-semibold">
<view class="box-border flex flex-col gap-3 p-4 pb-0">
<view class="flex items-center gap-x-2">
<image :src="result.owner.avatar" class="block w-6 h-6 rounded-full uh-global-card-glass"
mode="aspectFill"></image>
<text class="text-sm font-semibold">{{ result?.owner?.displayName }}</text>
</view>
<view class="font-semibold">
{{ result?.spec.title }}
</view>
<view class="detail mt-6 w-full text-[26rpx]">
<view class="author text-center text-[24rpx] text-[#666]">
<text class="author-name">作者{{ result?.owner?.displayName || bloggerInfo.nickname }}</text>
<text class="author-time ml-9">时间{{ formatPublishTime(result?.spec.publishTime) }}</text>
</view>
<view v-if="result?.spec.cover" class="cover mt-6 h-[280rpx] w-full">
<image
class="cover-img h-full w-full rounded-xl" mode="aspectFill"
:src="calcUrl(result.spec.cover)"
@click="handlePreview(0, [{ url: calcUrl(result.spec.cover) }])"
/>
</view>
<view
class="count mt-6 flex justify-between"
:class="{ 'no-thumbnail border-t-2 border-[#f2f2f2] pt-3': !result?.spec.cover }"
>
<view class="count-item flex flex-1 items-end justify-center text-[#666]">
<text class="value text-[32rpx]">{{ result?.stats?.visit ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">阅读</text>
</view>
<view class="count-item flex flex-1 items-end justify-center text-[#666]">
<text class="value text-[32rpx]">{{ result?.stats?.upvote ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">喜欢</text>
</view>
<view
v-if="calcIsShowComment"
class="count-item flex flex-1 items-end justify-center text-[#666]"
>
<text class="value text-[32rpx]">{{ result?.stats?.comment ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">评论</text>
</view>
<view class="count-item flex flex-1 items-end justify-center text-[#666]">
<text class="value text-[32rpx]">{{ result?.content?.raw.length || 0 }}</text>
<text class="label pl-2 text-[24rpx]">字数</text>
</view>
</view>
</view>
</view>
<!-- 分类标签 -->
<view class="category rounded-xl bg-white p-4 text-xs">
<view class="category-type leading-[55rpx]">
<text class="category-label font-bold">分类</text>
<text
v-if="!result?.categories?.length"
class="text-xs"
>
未选择分类
</text>
<template v-else>
<text
v-for="(item, index) in result?.categories" :key="index"
class="text-xs"
@click="handleToCate(item)"
>
<view class="flex flex-wrap items-center gap-2 text-xs">
<text v-for="(item, index) in result?.categories" :key="index"
class="uh-global-card-glass border uh-shadow-xs rounded-full px-2 py-1" @click="handleToCate(item)">
{{ item.spec.displayName }}
</text>
</template>
</view>
<view class="category-type leading-[55rpx]">
<text class="category-label font-bold">标签</text>
<text
v-if="!result?.tags?.length"
class="text-xs"
>
未选择标签
<text v-for="(item, index) in result?.tags" :key="index"
class="uh-global-card-glass border uh-shadow-xs rounded-full px-2 py-1" @click="handleToTag(item)">
#{{ item.spec.displayName }}
</text>
<template v-else>
<text
v-for="(item, index) in result?.tags" :key="index"
class="text-xs"
@click="handleToTag(item)"
>
{{ item.spec.displayName }}
</text>
</template>
</view>
<view v-if="originalURL" class="category-type flex leading-[55rpx]">
<view class="original-url-left w-[84rpx] shrink-0 font-bold">
原文
</view>
<view class="original-url-right inline-flex flex-1 items-center">
<text
class="original-url-link inline-block w-[410rpx] overflow-hidden text-ellipsis whitespace-nowrap text-[#909399]"
@click.stop="handleToOriginal(originalURL)"
>
<view class="uh-global-card-glass box-border p-3 uh-shadow-xs rounded-xl flex flex-col gap-2">
<view v-if="originalURL" class="flex flex-1 items-center gap-x-2 text-gray-500">
<text class="text-xs">原文</text>
<text class="text-xs text-gray-900" @click.stop="handleToOriginal(originalURL)">
{{originalURL}}
</text>
<text
class="original-url-btn flex-1 text-right text-[#03a9f4]"
@click.stop="handleToOriginal(originalURL)"
>
阅读原文
</text>
</view>
<view class="flex flex-1 items-center gap-x-2 text-gray-500">
<text class="text-xs">日期</text>
<text class="text-xs text-gray-900">{{ formatPublishTime(result?.spec.publishTime) }}</text>
</view>
<view class="flex items-center">
<view class="flex flex-1 items-center gap-x-2 text-gray-500">
<text class="text-xs">阅读</text>
<text class="text-xs text-gray-900">{{ result?.stats?.visit ?? 0 }}</text>
</view>
<view class="flex flex-1 items-center gap-x-2 text-gray-500">
<text class="text-xs">喜欢</text>
<text class="text-xs text-gray-900">{{ result?.stats?.upvote ?? 0 }}</text>
</view>
<view v-if="calcIsShowComment" class="flex flex-1 items-center gap-x-2 text-gray-500">
<text class="text-xs">评论</text>
<text class="text-xs text-gray-900">{{ result?.stats?.comment ?? 0 }}</text>
</view>
<view class="flex flex-1 items-center gap-x-2 text-gray-500">
<text class="text-xs">字数</text>
<text class="text-xs text-gray-900">{{ result?.content?.raw.length || 0 }}</text>
</view>
</view>
</view>
</view>
<!-- 内容区域 -->
<view class="content">
<view class="markdown-wrap overflow-hidden rounded-xl bg-white p-1.5">
<view class="flex flex-col gap-y-3 px-1">
<!-- 受限阅读 -->
<template v-if="checkPostRestrictRead(result!)">
<view v-if="showContentArr.length === 0">
<uh-restrict-read-skeleton
:loading="true" :lines="3"
<uh-restrict-read-skeleton :loading="true" :lines="3"
:tip-text="`此处内容已隐藏,「${getRestrictReadTypeName(result!)}可见」`"
:button-text="getRestrictReadTypeName(result!)" button-color="#1890ff"
@refresh="readMore"
/>
@refresh="readMore" />
</view>
<view v-for="(showContent, showContentIndex) in showContentArr" v-else :key="showContentIndex">
<mp-html
class="evan-markdown" lazy-load :domain="markdownConfig.domain ?? ''"
<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="showContent" :markdown="true" :show-line-number="true"
:show-language-name="true" copy-by-long-press
/>
<uh-restrict-read-skeleton
:loading="true" :lines="3"
:show-language-name="true" copy-by-long-press />
<uh-restrict-read-skeleton :loading="true" :lines="3"
:tip-text="`此处内容已隐藏,「${getRestrictReadTypeName(result!)}可见」`"
:button-text="getRestrictReadTypeName(result!)" button-color="#1890ff"
@refresh="readMore"
/>
@refresh="readMore" />
</view>
</template>
<!-- 正常渲染 -->
<template v-else>
<mp-html
class="evan-markdown" lazy-load :domain="markdownConfig.domain ?? ''"
<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="result?.content?.raw || ''" :markdown="true" :show-line-number="true"
:show-language-name="true" copy-by-long-press
/>
:show-language-name="true" copy-by-long-press />
</template>
</view>
<!-- 版权声明 -->
<view
v-if="postDetailConfig?.copyrightEnabled"
class="card-wrap rounded-xl bg-white p-4"
>
<view class="card-title relative box-border pl-6 text-[30rpx] font-bold">
<text class="absolute left-0 top-1 h-[26rpx] w-1 rounded-lg bg-[#03aefc]" />
版权声明
</view>
<view class="copyright-content mt-3 rounded-xl bg-[#fafafa] px-6 py-1.5">
<view
v-if="postDetailConfig.copyrightAuthor"
class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]"
>
<view v-if="postDetailConfig?.copyrightEnabled" class="box-border px-2">
<view class="uh-global-card-glass p-3 rounded-xl uh-shadow-xs">
<uh-section-title>版权声明</uh-section-title>
<view class="mt-3 flex flex-col gap-y-2 text-gray-600">
<view v-if="postDetailConfig.copyrightAuthor" class="text-sm leading-5">
版权归属{{ postDetailConfig.copyrightAuthor }}
</view>
<view
v-if="postDetailConfig.copyrightDesc"
class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]"
>
<view v-if="postDetailConfig.copyrightDesc" class="text-sm leading-5">
版权说明{{ postDetailConfig.copyrightDesc }}
</view>
<view
v-if="postDetailConfig.copyrightViolation"
class="copyright-text text-[26rpx] text-[#f56c6c] leading-[1.7]"
>
<view v-if="postDetailConfig.copyrightViolation" class="text-sm text-red-400 leading-5">
侵权处理{{ postDetailConfig.copyrightViolation }}
</view>
</view>
</view>
</view>
<!-- 评论区域 -->
<view v-if="calcIsShowComment && result" class="card-wrap rounded-xl bg-white p-4">
<uh-comment-list
:disallow-comment="!result.spec.allowComment" :post-name="result.metadata.name"
:post="result" @on-comment="handleOnComment" @on-comment-detail="handleOnShowCommentDetail"
/>
<uh-comment-list v-if="calcIsShowComment && result" :disallow-comment="!result.spec.allowComment"
:post-name="result.metadata.name" :post="result" @on-comment="handleOnComment"
@on-comment-detail="handleOnShowCommentDetail" />
</view>
</view>
<!-- 悬浮操作 -->
<view class="flot-buttons fixed bottom-[100rpx] right-4 z-99 flex flex-col gap-1.5">
<view class="fixed bottom-8 left-1/2 -translate-x-1/2 z-10 flex items-center justify-center pb-safe">
<!-- #7BE200 -->
<view
class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm"
@click="handleToTopPage()"
>
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
class="uh-global-card-glass border rounded-full box-border p-1 flex items-center justify-center gap-2 text-primary">
<view
class="box-border uh-global-card-glass shadow-none border px-4 h-[72rpx] flex-1 flex gap-x-1 items-center justify-center rounded-full"
:class="{ active: hasUpvoted() }" @click="handleDoLikes">
<wd-icon :name="hasUpvoted?'heart-fill':'heart'" size="20px" />
<text class="shrink-0 text-sm font-semibold text-gray-900">点赞</text>
</view>
<view v-if="calcIsShowComment"
class="box-border uh-global-card-glass shadow-none border px-4 h-[72rpx] flex-1 flex gap-x-1 items-center justify-center rounded-full"
@click="handleToComment()">
<wd-icon name="message" size="20px" />
<text class="shrink-0 text-sm font-semibold text-gray-900">评论</text>
</view>
<view
class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm"
:class="{ active: hasUpvoted() }" @click="handleDoLikes"
>
<wd-icon
name="heart" size="20px"
:color="hasUpvoted() ? '#f44336' : '#03a9f4'"
/>
</view>
<view
v-if="calcIsShowComment"
class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm"
@click="handleToComment()"
>
<wd-icon name="message" size="20px" color="#4caf50" />
class="box-border uh-global-card-glass shadow-none border px-4 h-[72rpx] flex-1 flex gap-x-1 items-center justify-center rounded-full"
@click="handleToComment()">
<wd-icon name="no-collection" size="20px" />
<text class="shrink-0 text-sm font-semibold text-gray-900">收藏</text>
</view>
</view>
</view>
</view>
</block>
<!-- 密码弹窗 -->
<wd-dialog
v-model="passwordModal.show" title="验证提示" :show-cancel="true" show-confirm-button confirm-text="确定"
@confirm="restrictReadCheck"
>
<wd-dialog v-model="passwordModal.show" title="验证提示" :show-cancel="true" show-confirm-button confirm-text="确定"
@confirm="restrictReadCheck">
<view class="modal-body py-4">
<wd-input v-model="restrictReadInputCode" placeholder="请输入密码" />
</view>
</wd-dialog>
<!-- 验证码弹窗 -->
<wd-dialog
v-model="verificationCodeModal.show" title="验证提示" :show-cancel="true" confirm-text="确定"
@confirm="restrictReadCheck"
>
<wd-dialog v-model="verificationCodeModal.show" title="验证提示" :show-cancel="true" confirm-text="确定"
@confirm="restrictReadCheck">
<view class="modal-body py-4">
<image
v-if="verificationCodeModal.imgUrl" :src="verificationCodeModal.imgUrl"
class="modal-code-img mb-4 h-[200rpx] w-full" mode="aspectFit"
/>
<image v-if="verificationCodeModal.imgUrl" :src="verificationCodeModal.imgUrl"
class="modal-code-img mb-4 h-[200rpx] w-full" mode="aspectFit" />
<wd-input v-model="restrictReadInputCode" placeholder="请输入验证码" class="mt-2" />
</view>
</wd-dialog>
<!-- 评论弹窗 -->
<uh-comment-modal
v-if="commentModal.show" :show="commentModal.show" :is-comment="commentModal.isComment"
:title="commentModal.title" :post-name="commentModal.postName" @on-close="handleOnCommentModalClose"
/>
<uh-comment-modal v-if="commentModal.show" :show="commentModal.show" :is-comment="commentModal.isComment"
:title="commentModal.title" :post-name="commentModal.postName" @on-close="handleOnCommentModalClose" />
</view>
</template>
<style scoped lang="scss">
.app-page {
display: flex;
flex-direction: column;
}
.head {
.detail {
.author {
.author-time {
margin-left: 36rpx;
}
}
}
}
.fab-btn {
box-shadow: 0 4rpx 16rpx rgb(0 0 0 / 10%);
&.active {
background-color: #fef0f0;
}
}
</style>
+29 -12
View File
@@ -35,7 +35,11 @@ const bloggerInfo = computed(() => {
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
const startConfig = computed(() => haloConfigs.value.appConfig?.startConfig as { title?: string } | undefined)
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
const siteName = computed(() => {
const appInfo = haloConfigs.value.appConfig?.appInfo as { name?: string } | undefined
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
})
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
@@ -73,16 +77,18 @@ async function handleGetData() {
const res = await getMomentByName(queryName.value)
uni.setNavigationBarTitle({ title: '瞬间详情' })
const medium = (res.data.spec as unknown as { medium?: { type?: string, url: string }[] }).medium || []
const medium = (res.data.spec.content?.medium || [])
.map(x => ({ ...x, url: x.url || '' }))
const owner = res.data.owner
const tempResult = {
...res.data,
// 无顶层 owner(如个别历史接口)时兜底为博主信息
owner: owner?.displayName
? owner
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
spec: {
...res.data.spec,
owner: {
displayName: bloggerInfo.value.nickname,
avatar: bloggerInfo.value.avatar,
},
newHtml: removeTagLinksCompletely((res.data.spec as unknown as { content?: { html?: string } }).content?.html || ''),
newHtml: removeTagLinksCompletely(res.data.spec.content?.html || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
@@ -170,11 +176,11 @@ onPullDownRefresh(() => {
onShareAppMessage(() => ({
path: `/pages-blog/moment-detail/moment-detail?name=${moment.value?.metadata.name}`,
title: moment.value?.spec.owner?.displayName || '',
title: moment.value?.owner?.displayName || '',
}))
onShareTimeline(() => ({
title: moment.value?.spec.owner?.displayName || '',
title: moment.value?.owner?.displayName || '',
query: moment.value ? `name=${moment.value.metadata.name}` : '',
}))
</script>
@@ -189,14 +195,25 @@ onShareTimeline(() => ({
<view v-if="moment" class="moment-card flex flex-col gap-6 p-6">
<!-- 用户信息 -->
<view class="card flex items-center rounded-xl bg-white p-6 shadow-sm">
<image class="avatar h-[80rpx] w-[80rpx] shrink-0 rounded-full" :src="moment.spec.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<image class="avatar h-[80rpx] w-[80rpx] shrink-0 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="nickname ml-3">
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
{{ moment.spec.owner?.displayName || bloggerInfo.nickname }}
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1.5 text-[24rpx] text-[#666]">
{{ formatTime(moment.spec.releaseTime) }}
</view>
<!-- 互动数据(点赞/评论) -->
<view v-if="moment.stats && ((moment.stats.totalComment ?? 0) > 0 || (moment.stats.upvote ?? 0) > 0)" class="stats mt-1.5 flex items-center gap-6 text-[24rpx] text-[#8a919e]">
<view class="flex items-center gap-1">
<wd-icon name="heart" size="13px" color="#f08585" />
<text>{{ moment.stats.upvote || 0 }}</text>
</view>
<view class="flex items-center gap-1">
<wd-icon name="message" size="13px" color="#9aa3b2" />
<text>{{ moment.stats.totalComment || 0 }}</text>
</view>
</view>
</view>
</view>
@@ -255,7 +272,7 @@ onShareTimeline(() => ({
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${startConfig?.title || bloggerInfo.nickname}的声音`"
:name="`来自${siteName}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
+265 -187
View File
@@ -1,172 +1,217 @@
<script lang="ts" setup>
/**
* 应用设置页(源自旧项目 pagesA/setting,新建复刻)
* 布局设置(首页布局/文章卡片样式)+ 功能设置(瀑布流/友链简洁/圆头像/轮播指示器)+ 保存/恢复默认
* 偏好设置页(两层:站点默认 L0 + 本地差异 L1-L,设计见 .docs/config-system-v2-redesign §3-4)
* - 每项可「跟随站点默认」(差异为空)或覆盖为具体值;改动即写本地差异(uh_pref_local_v1)即时生效;
* - 底部「重置为站点默认」= 清空全部本地差异,回退站长在插件后台配置的默认(未配置则为内置默认)。
*/
import { reactive, ref, watch } from 'vue'
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { DefaultAppSettings } from '@/config/appSettings'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import type { IAppSettings } from '@/config/appSettings'
import { collectSiteDefaults, isLocalOverride, readLocalPrefs } from '@/utils/preference'
import type { LocalPrefs } from '@/utils/preference'
definePage({
style: {
navigationBarTitleText: '应用设置',
navigationBarTitleText: '偏好设置',
},
})
const settingStore = useSettingStore()
const appConfigStore = useAppConfigStore()
/* ---------------- 状态 ---------------- */
const loading = ref(true)
const isSaved = ref(true)
const firstLoad = ref(true)
/** 本地编辑副本(不与 store 直接双向绑定,保存时提交) */
const appSettings = reactive<IAppSettings>(JSON.parse(JSON.stringify(DefaultAppSettings)))
/* ---------------- 选择器配置 ---------------- */
const homeLayout = reactive({
list: [
{ name: '一行一列', value: 'h_row_col1' },
{ name: '一行两列', value: 'h_row_col2' },
],
selectLabel: '一行一列',
selectValue: 'h_row_col1',
/** 确保启动合并已执行(入口页未跑或 H5 直达时兜底) */
onLoad(() => {
if (!settingStore.siteDefaults) {
settingStore.applySiteDefaults(collectSiteDefaults(appConfigStore.configs))
}
uni.setNavigationBarTitle({ title: '偏好设置' })
})
const articleCardStyle = reactive({
list: [
{ name: '左图右文', value: 'lr_image_text' },
{ name: '左文右图', value: 'lr_text_image' },
{ name: '上图下文', value: 'tb_image_text' },
{ name: '上文下图', value: 'tb_text_image' },
{ name: '只有文字', value: 'only_text' },
],
selectLabel: '左图右文',
selectValue: 'lr_image_text',
})
/* ---------------- 路径取值工具 ---------------- */
type Path = string[]
const dotPositionList = reactive([
{ name: '右边', value: 'right', checked: true },
{ name: '下边', value: 'bottom', checked: false },
])
/* ---------------- 工具 ---------------- */
function handleFindObjInList<T extends Record<string, unknown>>(list: T[], key: string, value: unknown): T {
return list.find(x => x[key] === value) || list[0]
function getByPath(obj: unknown, path: Path): unknown {
let cursor: unknown = obj
for (const key of path) {
if (cursor === null || cursor === undefined)
return undefined
cursor = (cursor as Record<string, unknown>)[key]
}
return cursor
}
/** 统一处理选择框回显 */
function handleHandleFormatSelect() {
const _homeLayout = handleFindObjInList(homeLayout.list, 'value', appSettings.layout.home)
homeLayout.selectLabel = _homeLayout.name
homeLayout.selectValue = _homeLayout.value
/** 按路径构造差异 patch(null 表示删除该键=跟随站点默认) */
function buildPatch(path: Path, value: unknown): LocalPrefs {
const [head, ...rest] = path
if (rest.length === 0)
return { [head]: value } as LocalPrefs
return { [head]: buildPatch(rest, value) } as LocalPrefs
}
const _cardStyle = handleFindObjInList(articleCardStyle.list, 'value', appSettings.layout.cardType)
articleCardStyle.selectLabel = _cardStyle.name
articleCardStyle.selectValue = _cardStyle.value
/** 偏好字段定义(现页已有项;弹幕已下线、友链分组二期再开) */
interface PrefDef {
key: string
label: string
kind: 'bool' | 'enum'
path: Path
options?: { label: string, value: string }[]
siteLabelOf?: (value: string) => string
}
const _dot = handleFindObjInList(dotPositionList, 'value', appSettings.banner.dotPosition)
dotPositionList.forEach((item) => {
item.checked = item.value === _dot.value
})
const layoutPrefs: PrefDef[] = [
{
key: 'home',
label: '首页文章布局',
kind: 'enum',
path: ['layout', 'home'],
options: [
{ label: '一行一列', value: 'h_row_col1' },
{ label: '一行两列', value: 'h_row_col2' },
],
},
{
key: 'cardType',
label: '文章卡片样式',
kind: 'enum',
path: ['layout', 'cardType'],
options: [
{ label: '左图右文', value: 'lr_image_text' },
{ label: '左文右图', value: 'lr_text_image' },
{ label: '上图下文', value: 'tb_image_text' },
{ label: '上文下图', value: 'tb_text_image' },
{ label: '只有文字', value: 'only_text' },
],
},
]
const featurePrefs: PrefDef[] = [
{ key: 'useWaterfull', label: '图库瀑布流模式', kind: 'bool', path: ['gallery', 'useWaterfull'] },
{ key: 'useSimple', label: '友链简洁模式', kind: 'bool', path: ['links', 'useSimple'] },
{ key: 'isAvatarRadius', label: '是否圆形头像', kind: 'bool', path: ['isAvatarRadius'] },
{ key: 'useDot', label: '轮播图指示器', kind: 'bool', path: ['banner', 'useDot'] },
{
key: 'dotPosition',
label: '指示器位置',
kind: 'enum',
path: ['banner', 'dotPosition'],
options: [
{ label: '右边', value: 'right' },
{ label: '下边', value: 'bottom' },
],
siteLabelOf: (value: string) => {
const map: Record<string, string> = { right: '右边', bottom: '下边', left: '左边', top: '上边' }
return map[value] || value
},
},
]
/* ---------------- 状态读取 ---------------- */
function valueOf(path: Path): unknown {
return getByPath(settingStore.settings, path)
}
/** 站点默认值(未配置时回退内置默认) */
function siteDefaultOf(path: Path): unknown {
const site = getByPath(settingStore.siteDefaults, path)
if (site !== undefined && site !== null)
return site
return getByPath(DefaultAppSettings, path)
}
function isOverridden(path: Path): boolean {
return isLocalOverride(readLocalPrefs(), path)
}
function enumLabelOf(def: PrefDef, value: unknown): string {
const hit = def.options?.find(opt => opt.value === value)
if (hit)
return hit.label
if (def.siteLabelOf && typeof value === 'string')
return def.siteLabelOf(value)
return value === undefined || value === null ? '—' : String(value)
}
/* ---------------- 交互 ---------------- */
function handleOnHomeLayoutConfirm() {
const _select = handleFindObjInList(homeLayout.list, 'value', appSettings.layout.home)
homeLayout.selectLabel = _select.name
homeLayout.selectValue = _select.value
/** 开关事件(模板透传 $event) */
function handleSwitchChange(def: PrefDef, detail: { value?: unknown }) {
handleBoolChange(def.path, detail.value === true)
}
function handleOnArticleCardStyleConfirm() {
const _select = handleFindObjInList(articleCardStyle.list, 'value', appSettings.layout.cardType)
articleCardStyle.selectLabel = _select.name
articleCardStyle.selectValue = _select.value
}
function handleOnBannerDotChange(e: { value?: string }) {
const value = e.value || 'right'
appSettings.banner.dotPosition = value
}
/** 保存设置 */
function handleOnSave() {
isSaved.value = true
settingStore.settings = JSON.parse(JSON.stringify(appSettings))
uni.showToast({ icon: 'none', title: '保存成功,部分设置在重启后生效!' })
}
/** 恢复默认设置 */
function handleOnSaveDefault() {
uni.showModal({
title: '提示',
content: '您确定要恢复为默认的设置吗?',
showCancel: true,
cancelText: '否',
cancelColor: '#999999',
confirmText: '是',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
isSaved.value = true
settingStore.updateDefaultAppSettings()
Object.assign(appSettings, JSON.parse(JSON.stringify(DefaultAppSettings)))
handleHandleFormatSelect()
uni.showToast({ icon: 'none', title: '系统设置已恢复为默认配置,部分设置在重启后生效!' })
}
},
})
}
function handleOnBack() {
if (isSaved.value) {
uni.navigateBack()
return
}
uni.showModal({
title: '提示',
content: '您当前可能有未保存的数据,确定返回吗?',
showCancel: true,
cancelText: '否',
cancelColor: '#999999',
confirmText: '是',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
uni.navigateBack()
isSaved.value = true
}
},
})
}
/* ---------------- 监听 ---------------- */
watch(appSettings, () => {
if (firstLoad.value) {
firstLoad.value = false
/** 开关类:选值等于站点默认则还原为跟随(只存差异) */
function handleBoolChange(path: Path, next: boolean) {
if (next === siteDefaultOf(path)) {
settingStore.savePreference(buildPatch(path, null))
}
else {
isSaved.value = false
settingStore.savePreference(buildPatch(path, next))
}
}
}, { deep: true })
onLoad(() => {
uni.setNavigationBarTitle({ title: '应用设置' })
Object.assign(appSettings, JSON.parse(JSON.stringify(settingStore.settings)))
handleHandleFormatSelect()
uni.showLoading({ title: '加载中...', mask: true })
setTimeout(() => {
loading.value = false
uni.hideLoading()
}, 500)
/** 单项还原为跟随站点默认 */
function handleRevert(path: Path) {
settingStore.savePreference(buildPatch(path, null))
}
/* ---------------- 枚举底部弹层 ---------------- */
const enumSheet = ref<{ show: boolean, def: PrefDef | null }>({ show: false, def: null })
function handleOpenEnum(def: PrefDef) {
enumSheet.value = { show: true, def }
}
function handleCloseEnum() {
enumSheet.value.show = false
}
function handleChooseEnum(value: string | null) {
const def = enumSheet.value.def
if (def) {
if (value === null || value === siteDefaultOf(def.path)) {
handleRevert(def.path)
}
else {
settingStore.savePreference(buildPatch(def.path, value))
}
}
handleCloseEnum()
}
/** 当前枚举项是否处于「跟随站点默认」 */
function isFollowing(def: PrefDef): boolean {
return !isOverridden(def.path)
}
/* ---------------- 重置全部 ---------------- */
function handleResetAll() {
uni.showModal({
title: '提示',
content: '确定将所有偏好恢复为站点默认吗?本地自定义的偏好将被清除,未配置站点默认的项将恢复为内置默认。',
showCancel: true,
cancelText: '取消',
confirmText: '确定',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
settingStore.resetPreferences()
enumSheet.value.show = false
uni.showToast({ icon: 'none', title: '已恢复为站点默认' })
}
},
})
}
</script>
<template>
<view class="app-page box-border min-h-screen pb-[140rpx]" style="background-color: #fafafd;">
<view v-if="!loading">
<view class="app-page box-border min-h-screen pb-[180rpx]" style="background-color: #fafafd;">
<!-- 说明 -->
<view class="pref-tip mx-6 mt-6 rounded-xl bg-white px-6 py-4 shadow-sm">
<text class="tip-text text-[22rpx] leading-[1.6] text-[#909399]">
你的偏好仅保存在本机未自定义的项自动跟随站长在插件后台配置的站点默认点击底部重置为站点默认可清空全部本地偏好
</text>
</view>
<!-- 布局设置 -->
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
@@ -174,19 +219,19 @@ onLoad(() => {
<text class="title-desc ml-3 text-[22rpx] text-[#999]">应用以及文章列表布局设置</text>
</view>
<view class="sheet-content">
<!-- 首页布局 -->
<view class="pick-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7" @click="handleOnHomeLayoutConfirm">
<text class="row-label text-[28rpx] text-[#333]">首页文章布局</text>
<view class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-[#999]">{{ homeLayout.selectLabel }}</text>
<wd-icon name="arrow-right" size="12px" color="#999" />
<view
v-for="def in layoutPrefs"
:key="def.key"
class="pick-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7"
@click="handleOpenEnum(def)"
>
<view class="row-left flex flex-col">
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
<text v-if="isFollowing(def)" class="row-sub mt-1 text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
<text v-else class="row-sub mt-1 text-[20rpx] text-[#03a9f4]">已自定义</text>
</view>
</view>
<!-- 文章卡片样式 -->
<view class="pick-row flex items-center justify-between px-8 py-7" @click="handleOnArticleCardStyleConfirm">
<text class="row-label text-[28rpx] text-[#333]">文章卡片样式</text>
<view class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-[#999]">{{ articleCardStyle.selectLabel }}</text>
<text class="value-text text-[26rpx] text-[#999]">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
<wd-icon name="arrow-right" size="12px" color="#999" />
</view>
</view>
@@ -200,61 +245,94 @@ onLoad(() => {
<text class="title-desc ml-3 text-[22rpx] text-[#999]">一些常用的功能性设置</text>
</view>
<view class="sheet-content">
<view class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">图库瀑布流模式</text>
<wd-switch v-model="appSettings.gallery.useWaterfull" />
</view>
<view class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">友链简洁模式</text>
<wd-switch v-model="appSettings.links.useSimple" />
</view>
<view class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">是否圆形头像</text>
<wd-switch v-model="appSettings.isAvatarRadius" />
</view>
<view class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">轮播图指示器</text>
<wd-switch v-model="appSettings.banner.useDot" />
</view>
<!-- 指示器位置 -->
<view v-if="appSettings.banner.useDot" class="switch-row flex items-center justify-between px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">指示器位置</text>
<view class="radio-group flex gap-4">
<view
v-for="item in dotPositionList"
:key="item.value"
class="radio-item rounded-3xl px-6 py-1 text-[24rpx] text-[#999]"
:class="item.checked ? 'bg-[#03a9f4] text-white' : 'bg-[#f5f5f5]'"
@click="item.checked = true; handleOnBannerDotChange({ value: item.value })"
>
{{ item.name }}
<template v-for="def in featurePrefs" :key="def.key">
<!-- 布尔开关 -->
<view v-if="def.kind === 'bool'" class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-5">
<view class="row-left flex flex-col">
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
<view class="mt-1 flex items-center gap-2">
<text v-if="isFollowing(def)" class="row-sub text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
<text v-else class="row-sub text-[20rpx] text-[#03a9f4]">已自定义</text>
<text
v-if="!isFollowing(def)"
class="revert-text text-[20rpx] text-[#909399] underline"
@click.stop="handleRevert(def.path)"
>跟随默认</text>
</view>
</view>
<wd-switch :model-value="!!valueOf(def.path)" @change="handleSwitchChange(def, $event)" />
</view>
<!-- 枚举选择(指示器位置) -->
<view v-else class="pick-row flex items-center justify-between px-8 py-5" @click="handleOpenEnum(def)">
<view class="row-left flex flex-col">
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
<view class="mt-1 flex items-center gap-2">
<text v-if="isFollowing(def)" class="row-sub text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
<text v-else class="row-sub text-[20rpx] text-[#03a9f4]">已自定义</text>
<text
v-if="!isFollowing(def)"
class="revert-text text-[20rpx] text-[#909399] underline"
@click.stop="handleRevert(def.path)"
>跟随默认</text>
</view>
</view>
<view class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-[#999]">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
<wd-icon name="arrow-right" size="12px" color="#999" />
</view>
</view>
</template>
</view>
</view>
<!-- 操作区域 -->
<view v-if="!loading" class="btn-bar fixed bottom-0 left-0 box-border w-screen flex gap-6 bg-white p-6 shadow-sm">
<wd-button type="primary" size="medium" @click="handleOnSave">
保存设置
</wd-button>
<wd-button type="danger" size="medium" @click="handleOnSaveDefault">
恢复默认设置
</wd-button>
<wd-button plain size="medium" @click="handleOnBack">
返回
<view class="btn-bar fixed bottom-0 left-0 box-border w-screen bg-white p-6 shadow-sm">
<wd-button type="danger" size="medium" block @click="handleResetAll">
重置为站点默认
</wd-button>
</view>
<!-- 枚举选择底部弹层 -->
<wd-popup
v-model="enumSheet.show"
position="bottom"
closable
custom-style="border-radius: 24rpx 24rpx 0 0;"
@close="handleCloseEnum"
>
<view v-if="enumSheet.def" class="enum-sheet box-border w-full pb-[env(safe-area-inset-bottom)]">
<view class="enum-title border-b border-[#f5f5f5] py-6 text-center text-[30rpx] font-bold text-[#303133]">
{{ enumSheet.def.label }}
</view>
<view
class="enum-item flex items-center justify-between px-8 py-6"
:class="isFollowing(enumSheet.def) ? 'text-[#03a9f4]' : 'text-[#333]'"
@click="handleChooseEnum(null)"
>
<text class="text-[28rpx]">跟随站点默认</text>
<wd-icon v-if="isFollowing(enumSheet.def)" name="check" size="16px" color="#03a9f4" />
</view>
<view
v-for="opt in enumSheet.def.options"
:key="opt.value"
class="enum-item flex items-center justify-between border-t border-[#f5f5f5] px-8 py-6"
:class="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def) ? 'text-[#03a9f4]' : 'text-[#333]'"
@click="handleChooseEnum(opt.value)"
>
<text class="text-[28rpx]">{{ opt.label }}</text>
<wd-icon
v-if="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def)"
name="check"
size="16px"
color="#03a9f4"
/>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.btn-bar {
:deep(wd-button) {
flex: 1;
+8 -32
View File
@@ -7,6 +7,8 @@ import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getQRCodeInfo } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { collectSiteDefaults } from '@/utils/preference'
import { usePluginAvailable } from '@/utils/plugin'
definePage({
@@ -21,7 +23,6 @@ definePage({
/* ---------------- 常量 ---------------- */
const homePagePath = '/pages/tabbar/home/home'
const startPagePath = '/pages/start/start'
const articleDetailPath = '/pages-blog/article-detail/article-detail'
// 本地开发快速跳转页面,发布请置为 false
@@ -31,6 +32,7 @@ const DEV_TO_PATH = `${articleDetailPath}?name=01a057b2-3200-74af-8afe-28a054092
/* ---------------- 状态 ---------------- */
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const uniHaloPluginId = 'plugin-uni-halo'
const uniHaloPluginAvailableError = '阿偶,检测到当前插件没有安装或者启用,无法启动 uni-halo 哦,请联系管理员'
const uniHaloPluginAvailable = ref(true)
@@ -60,35 +62,6 @@ async function handleAuditMode() {
await appConfigStore.fetchAuditData()
}
/** 启动页/首页分流 */
function handleCheckShowStarted() {
const appConfig = (appConfigStore.configs.appConfig ?? {}) as {
startConfig?: { enabled?: boolean, alwaysShow?: boolean }
}
const startConfig = appConfig.startConfig
// 未开启启动页,直接进首页
if (!startConfig?.enabled) {
uni.switchTab({ url: homePagePath })
return
}
// 是否每次都显示启动页
if (startConfig.alwaysShow) {
uni.removeStorageSync('APP_HAS_STARTED')
uni.redirectTo({ url: startPagePath })
return
}
// 只显示一次启动页
if (uni.getStorageSync('APP_HAS_STARTED')) {
uni.switchTab({ url: homePagePath })
}
else {
uni.redirectTo({ url: startPagePath })
}
}
onLoad(async (options) => {
// 本地开发,快速跳转页面,发布请设置 DEV_MODE = false
if (DEV_MODE && DEV_TO_PATH) {
@@ -128,8 +101,11 @@ onLoad(async (options) => {
// 审计模式数据(公开接口 /audit-data)
await handleAuditMode()
// 启动页分流
handleCheckShowStarted()
// 两层偏好合并:应用站点默认(L0)到 setting store(内部合并本地差异,含旧数据迁移)
settingStore.applySiteDefaults(collectSiteDefaults(appConfigStore.configs))
// 启动页已下线(v2.2 ⑤):直接进首页
uni.switchTab({ url: homePagePath })
}
catch (err) {
console.error('入口页初始化失败', err)
-299
View File
@@ -1,299 +0,0 @@
<script lang="ts" setup>
/**
* 启动页(源自旧项目 pagesA/start,新建复刻)
* 支持颜色/图片/视频/星空四种背景类型 + logo/标题/描述 + 开始按钮 + 波浪
*/
import { computed } from 'vue'
import { checkImageUrl, checkUrl } from '@/utils/url'
import { useAppConfigStore } from '@/store/appConfig'
definePage({
style: {
navigationBarTitleText: 'uni-halo',
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const startConfig = computed(() => (haloConfigs.value.appConfig?.startConfig as {
title?: string
logo?: string
desc1?: string
desc2?: string
btnText?: string
btnClass?: string
btnStyle?: string
titleStyle?: string
descStyle?: string
backgroundType?: string
bg?: string
bgImage?: string
bgImageFit?: string
bgVideo?: string
bgVideoFit?: string
useWave?: boolean
} | undefined) || {})
const calcBackgroundType = computed(() => startConfig.value.backgroundType || 'star')
const calcPageClass = computed(() => {
if (calcBackgroundType.value === 'color') {
return [startConfig.value.bg]
}
return []
})
const calcPageStyle = computed(() => {
if (calcBackgroundType.value === 'color') {
return {}
}
if (calcBackgroundType.value === 'image') {
return {
backgroundImage: `url(${checkImageUrl(startConfig.value.bgImage)}) !important`,
backgroundSize: startConfig.value.bgImageFit || 'cover',
}
}
if (calcBackgroundType.value === 'video') {
return {
background: '#ffffff',
}
}
return {}
})
function handleStart() {
uni.switchTab({
url: '/pages/tabbar/home/home',
success: () => {
uni.setStorageSync('APP_HAS_STARTED', true)
},
})
}
</script>
<template>
<view class="app-page relative h-screen w-screen" :class="calcPageClass" :style="[calcPageStyle]">
<!-- 星空背景 -->
<view v-if="calcBackgroundType !== 'video'" class="star-bg fixed z-998 h-[600px] w-full shrink-0 overflow-hidden">
<view class="stars absolute z-1 h-[400px] w-full">
<view class="falling-stars">
<view class="star-fall" />
<view class="star-fall" />
<view class="star-fall" />
<view class="star-fall" />
</view>
<view class="small-stars">
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
</view>
</view>
</view>
<!-- 视频背景 -->
<video
v-else
class="video-bg absolute left-0 top-0 z-0 h-screen w-screen"
:object-fit="(startConfig.bgVideoFit as 'contain' | 'cover') || 'cover'"
:src="checkUrl(startConfig.bgVideo)"
:loop="true"
:autoplay="true"
:muted="true"
:controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-center-play-btn="false"
:show-loading="false"
:enable-progress-gesture="false"
:show-progress="false"
/>
<!-- 标题区域 -->
<view v-if="startConfig.title || startConfig.logo" class="title-container absolute left-0 top-[20vh] z-999 w-screen flex flex-col items-center justify-center">
<view v-if="startConfig.logo" class="app-logo h-[200rpx] w-[200rpx]">
<view class="app-logo-border box-border h-full w-full overflow-hidden border-8 border-white/35 rounded-full">
<image class="app-logo-image h-full w-full rounded-full" :src="checkImageUrl(startConfig.logo)" mode="aspectFill" />
</view>
</view>
<view v-if="startConfig.title" class="app-title mt-6 text-center text-[36rpx] text-white font-semibold" :style="startConfig.titleStyle">
{{ startConfig.title }}
</view>
</view>
<!-- 底部区域 -->
<view class="bottom-container absolute bottom-[50rpx] left-1/2 z-999 flex flex-col items-center -translate-x-1/2">
<view class="desc-area pt-[60vh] text-white" :style="startConfig.descStyle">
<view v-show="startConfig.desc1" class="desc1 text-center text-[44rpx]">
{{ startConfig.desc1 }}
</view>
<view v-show="startConfig.desc2" class="desc2 mt-8 text-center text-[26rpx]">
{{ startConfig.desc2 }}
</view>
</view>
<view class="start-btn mb-[120rpx] mt-[60rpx] box-border border-2 border-white rounded-[50rpx] px-12 py-4 text-center text-[28rpx] text-white" :class="[startConfig.btnClass]" :style="[startConfig.btnStyle]" @click="handleStart">
{{ startConfig.btnText || '开始体验' }}
</view>
</view>
<!-- 波浪效果 -->
<image v-if="startConfig.useWave" class="wave-img absolute bottom-0 left-0 z-99 h-[100rpx] w-full" src="/static/wave/wave-1.png" mode="scaleToFill" />
</view>
</template>
<style scoped lang="scss">
.app-page {
background-size: cover;
background-repeat: no-repeat;
background: linear-gradient(180deg, #0f1e3d 0%, #1a3a6b 100%);
}
/* 星空背景(动画无法用 UnoCSS 表达,保留样式) */
.star-bg {
.star {
border-radius: 50%;
background: #fff;
box-shadow: 0 0 6px 0 rgb(255 255 255 / 80%);
}
.small-stars .star {
position: absolute;
width: 3px;
height: 3px;
opacity: 0;
animation: star-blink 1.2s linear infinite alternate;
&:nth-child(1) {
left: 40px;
bottom: 50px;
}
&:nth-child(2) {
left: 200px;
bottom: 40px;
}
&:nth-child(3) {
left: 60px;
bottom: 120px;
}
&:nth-child(4) {
left: 140px;
bottom: 250px;
}
&:nth-child(5) {
left: 400px;
bottom: 300px;
}
&:nth-child(6) {
left: 170px;
bottom: 80px;
}
&:nth-child(7) {
left: 200px;
bottom: 360px;
animation-delay: 0.2s;
}
&:nth-child(8) {
left: 250px;
bottom: 320px;
}
&:nth-child(9) {
left: 300px;
bottom: 340px;
}
&:nth-child(10) {
left: 130px;
bottom: 320px;
animation-delay: 0.5s;
}
&:nth-child(11) {
left: 230px;
bottom: 330px;
animation-delay: 0.7s;
}
&:nth-child(12) {
left: 300px;
bottom: 360px;
animation-delay: 0.3s;
}
}
.star-fall {
position: relative;
border-radius: 2px;
width: 80px;
height: 2px;
overflow: hidden;
transform: rotate(-20deg);
&::after {
content: '';
position: absolute;
width: 50px;
height: 2px;
background: linear-gradient(to left, rgb(0 0 0 / 0%) 0%, rgb(255 255 255 / 40%) 100%);
left: 100%;
animation: star-fall 3.6s linear infinite;
}
&:nth-child(1) {
left: 80px;
bottom: -100px;
&::after {
animation-delay: 2.4s;
}
}
&:nth-child(2) {
left: 200px;
bottom: -200px;
&::after {
animation-delay: 2s;
}
}
&:nth-child(3) {
left: 430px;
bottom: -50px;
&::after {
animation-delay: 3.6s;
}
}
&:nth-child(4) {
left: 400px;
bottom: 100px;
&::after {
animation-delay: 0.2s;
}
}
}
}
@keyframes star-blink {
50% {
opacity: 1;
}
}
@keyframes star-fall {
20% {
left: -100%;
}
100% {
left: -100%;
}
}
/* 波浪混合模式(无法用 UnoCSS 表达) */
.wave-img {
mix-blend-mode: screen;
}
</style>
+9
View File
@@ -159,6 +159,15 @@ async function handleGetNavList() {
path: '/pages-blog/about/about',
show: !!basicConfig.value?.showAboutSystem,
},
{
key: 'setting',
title: '偏好设置',
icon: 'settings',
iconColor: '#03a9f4',
rightText: '首页布局、卡片样式等本地偏好',
path: '/pages-blog/setting/setting',
show: true,
},
]
}
+32 -233
View File
@@ -1,20 +1,18 @@
<script lang="ts" setup>
/**
* 分类页(源自旧项目 pages/tabbar/category/category.vue,新建复刻)
* 两种视图:list(分类卡片网格)/ list-post(左侧分类导航 + 右侧文章列表)
*/
import { computed, ref, watch } from 'vue'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getCategoryList, getCategoryPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkThumbnailUrl } from '@/utils/url'
import { t } from '@/locale'
import { useDataLoadingStatus, DataLoadingStatusEnum } from '@/hooks/useDataLoadingStatus'
import type { ICategory, IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '分类',
enablePullDownRefresh: true,
backgroundColor: '#f6f3ee',
},
})
@@ -25,7 +23,7 @@ const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const categoryConfig = computed(() => haloConfigs.value.pageConfig?.categoryConfig)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const queryParams = ref({
size: 20,
page: 1,
@@ -33,48 +31,27 @@ const queryParams = ref({
})
const hasNext = ref(false)
const dataList = ref<ICategory[]>([])
const categoryList = ref<ICategory[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const currentCategoryConfig = ref<{ type?: string }>({ type: 'list' })
const currentCategoryName = ref('')
const postQueryParams = ref({ size: 10, page: 0 })
const postList = ref<IPost[]>([])
/* ---------------- 计算属性 ---------------- */
const calcShowType = computed(() => currentCategoryConfig.value.type)
/* ---------------- 视图切换 ---------------- */
function handleChangeShowType() {
currentCategoryConfig.value.type = calcShowType.value === 'list-post' ? 'list' : 'list-post'
handleInitPage()
}
function handleResetInit() {
postList.value = []
dataList.value = []
categoryList.value = []
queryParams.value.page = 1
postQueryParams.value.page = 0
hasNext.value = false
isLoadMore.value = false
loadMoreText.value = t('common.loading')
currentCategoryName.value = ''
}
function handleInitPage() {
handleResetInit()
if (calcShowType.value === 'list-post') {
queryParams.value.size = 99999
}
handleGetData()
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
// 审核模式
if (calcAuditModeEnabled.value) {
// 审核模式:真实分类按 audit-data categories 过滤(数组顺序即展示顺序)
currentCategoryConfig.value.type = 'list'
const auditCategoryNames = appConfigStore.auditData.spec?.categories || []
try {
const res = await getCategoryList({ page: 1, size: 99999 })
@@ -88,30 +65,28 @@ async function handleGetData() {
const orderMap = new Map(auditCategoryNames.map((name, index) => [name, index]))
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
dataList.value = filtered
loading.value = 'success'
updateLoadingStatus(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
}
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 getCategoryList({ ...queryParams.value })
if (calcShowType.value === 'list') {
loading.value = 'success'
updateLoadingStatus(DataLoadingStatusEnum.Success)
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext
@@ -124,23 +99,11 @@ async function handleGetData() {
dataList.value = isLoadMore.value
? dataList.value.concat(tempItems)
: tempItems
}
else {
dataList.value = res.data.items
categoryList.value = res.data.items.map(item => ({
...item,
postCount: item.postCount ?? 0,
}))
loading.value = 'success'
if (dataList.value.length !== 0) {
currentCategoryName.value = dataList.value[0].metadata.name
handleGetPostByCategory()
}
}
}
catch (err) {
console.error(err)
loading.value = 'error'
updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = t('common.loadFailed')
}
finally {
@@ -151,76 +114,24 @@ async function handleGetData() {
}
}
/** 获取当前分类下的文章 */
async function handleGetPostByCategory(isPulldownRefresh = true) {
if (!isPulldownRefresh) {
if (hasNext.value) {
postQueryParams.value.page += 1
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
}
else {
postQueryParams.value.page = 0
}
try {
const res = await getCategoryPostList(currentCategoryName.value, postQueryParams.value)
hasNext.value = res.data.hasNext
postList.value = isPulldownRefresh
? res.data.items
: postList.value.concat(res.data.items)
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
catch (err) {
loadMoreText.value = t('common.loadFailedShort')
console.error(err)
}
}
/* ---------------- 交互 ---------------- */
function handleOnCategoryChange(e: { detail: { current: number } }) {
const index = e.detail.current
if (!dataList.value[index])
return
currentCategoryName.value = dataList.value[index].metadata.name
postList.value = []
handleGetPostByCategory()
}
function handleToCategory(category : ICategory) {
if (calcAuditModeEnabled.value)
if (calcAuditModeEnabled.value) {
return
}
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
function handleToArticleDetail(post: IPost) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${post.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleScrollTop() {
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
}
/* ---------------- 生命周期 ---------------- */
watch(categoryConfig, (newVal) => {
if (!newVal)
return
currentCategoryConfig.value = newVal
uni.setNavigationBarTitle({ title: t('page.category.title') })
onMounted(() => {
handleInitPage()
}, { deep: true, immediate: true })
})
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 1
handleResetInit()
handleGetData()
})
@@ -230,16 +141,10 @@ onReachBottom(() => {
return
}
if (hasNext.value) {
if (calcShowType.value === 'list') {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
postQueryParams.value.page += 1
handleGetPostByCategory(false)
}
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
@@ -247,138 +152,32 @@ onReachBottom(() => {
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" :style="{ padding: calcShowType === 'list-post' ? '0' : '24rpx 0' }">
<view class="bg-page min-h-screen w-screen flex flex-col p-3 box-border">
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap px-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<!-- 内容区域 -->
<view v-else class="app-page-content flex flex-wrap gap-y-5 px-1.5" :class="[calcShowType === 'list-post' ? 'list-post' : '']">
<view v-if="dataList.length === 0" class="h-[70vh] flex items-center justify-center content-empty">
<wd-empty :description="t('common.empty')" />
<view v-if="loadingStatus !== DataLoadingStatusEnum.Success">
<uh-data-loading :loading-status="loadingStatus" />
</view>
<block v-else>
<!-- list 视图:分类卡片网格 -->
<block v-if="calcAuditModeEnabled || calcShowType === 'list'">
<view
v-for="(item, index) in dataList"
:key="index"
class="catgory-card box-border w-1/2 p-1"
:style="{ backgroundImage: `url(${item.spec.cover})` }"
>
<view class="catgory-card-content h-[200rpx] flex flex-col items-center justify-center overflow-hidden rounded-xl shadow-sm" @click="handleToCategory(item)">
<view class="catgory-name z-2 text-[32rpx] text-white">
<view class="grid grid-cols-2 gap-3">
<view v-for="(item, index) in dataList" :key="index"
class="relative w-full box-border rounded-xl overflow-hidden uh-global-card-glass"
@click="handleToCategory(item)">
<image v-if="item.spec.cover" class="block h-32 w-full" :src="item.spec.cover" mode="aspectFill" />
<view class="absolute bottom-0 left-0 h-[140rpx] w-full bg-gradient-to-b from-black/0 to-black/30" />
<view class="absolute bottom-0 left-0 box-border w-full p-2.5 flex flex-col gap-1">
<text class="text-sm text-white font-bold truncate">
{{ item.spec.displayName }}
</view>
<view v-if="!calcAuditModeEnabled" class="catgory-count z-2 mt-1 text-[24rpx] text-white">
</text>
<text class="text-xs text-white opacity-80">
{{ item.postCount }} 篇文章
</text>
</view>
</view>
</view>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
<view class="w-full py-5 text-center text-xs text-gray-400">
{{ loadMoreText }}
</view>
</block>
<!-- list-post 视图:左侧分类 + 右侧文章 -->
<view v-else class="list-post-wrapper min-h-screen w-screen flex">
<scroll-view class="left-nav w-[180rpx] shrink-0 bg-white" :scroll-y="true">
<view
v-for="(item, index) in categoryList"
:key="item.metadata.name"
class="left-nav-item border-l-4 px-4 py-8 text-center text-[26rpx] text-[#606266]"
:class="{ active: currentCategoryName === item.metadata.name }"
@click="handleOnCategoryChange({ detail: { current: index } })"
>
{{ item.spec.displayName }}
</view>
</scroll-view>
<scroll-view class="right-content box-border h-screen flex-1" :scroll-y="true">
<view v-if="postList.length === 0" class="article-empty flex items-center justify-center py-10">
<wd-empty description="该分类下暂无文章~" />
</view>
<block v-else>
<uh-article-min-card
v-for="(post, index) in postList"
:key="index"
:article="post"
@on-click="handleToArticleDetail"
/>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
</scroll-view>
</view>
</block>
</view>
<!-- 悬浮按钮 -->
<view class="flot-buttons fixed bottom-[100rpx] right-8 z-999 flex flex-col gap-1.5">
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleScrollTop">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view v-if="!calcAuditModeEnabled" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleChangeShowType">
<wd-icon :name="calcShowType === 'list' ? 'list' : 'grid'" size="20px" color="#03a9f4" />
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.app-page {
width: 100vw;
}
.app-page-content {
&.list-post {
padding: 0;
gap: 0;
}
}
.catgory-card {
> view {
position: relative;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.catgory-card-content::before {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgb(0 0 0 / 15%);
backdrop-filter: blur(3rpx);
z-index: 1;
}
}
.list-post-wrapper {
.left-nav {
.left-nav-item {
border-left-color: transparent;
&.active {
color: #03a9f4;
border-left-color: #03a9f4;
background-color: #f5f7fa;
font-weight: bold;
}
}
}
}
.flot-buttons {
.fab-btn {
box-shadow: 0 4rpx 16rpx rgb(0 0 0 / 10%);
}
}
</style>
+36 -78
View File
@@ -10,7 +10,7 @@ import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import type { IPhoto, IPhotoGroup } from '@/api/types/halo'
import type { ICategory, IPhoto, IPhotoGroup } from '@/api/types/halo'
definePage({
style: {
@@ -31,7 +31,7 @@ const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const category = ref<{ activeIndex: number, list: { name?: string, displayName: string, priority: number }[] }>({
const category = ref<{ activeIndex : number, list : IPhotoGroup[] }>({
activeIndex: 0,
list: [],
})
@@ -48,18 +48,13 @@ async function handleGetCategory() {
// 审核模式:仅展示所选图库分组(galleryGroups)内的照片,未分组照片不展示
const auditGroupNames = appConfigStore.auditData.spec?.galleryGroups || []
try {
const res = await getPhotoGroupList({ page: 1, size: 99999 })
const res = await getPhotoGroupList({ page: 1, size: 0 })
const filtered = ((res.data as unknown as IPhotoGroup[] | undefined) || [])
.filter(item => auditGroupNames.includes(item.metadata.name))
.map(item => ({
name: item.metadata.name,
displayName: item.spec.displayName,
priority: item.spec.priority ?? 0,
}))
.sort((a, b) => a.priority - b.priority)
.sort((a, b) => a.spec.priority - b.spec.priority)
category.value.list = filtered
if (category.value.list.length !== 0) {
queryParams.value.group = category.value.list[0].name || ''
queryParams.value.group = category.value.list[0].metadata.name || ''
handleGetData(true)
}
else {
@@ -78,15 +73,10 @@ async function handleGetCategory() {
try {
const res = await getPhotoGroupList({ page: 1, size: 0 })
category.value.list = ((res.data as unknown as IPhotoGroup[] | undefined) || [])
.map(item => ({
name: item.metadata.name,
displayName: item.spec.displayName,
priority: item.spec.priority ?? 0,
}))
.sort((a, b) => a.priority - b.priority)
category.value.list.unshift({ name: undefined, displayName: '全部', priority: 0 })
.sort((a, b) => a.spec.priority - b.spec.priority)
category.value.list.unshift({ metadata: { name: undefined }, spec: { displayName: '全部', priority: 0 } })
if (category.value.list.length !== 0) {
queryParams.value.group = category.value.list[0].name || ''
queryParams.value.group = category.value.list[0].metadata.name || ''
handleGetData(true)
}
}
@@ -137,23 +127,15 @@ async function handleGetData(isClearList = false) {
}
}
function handleGetDataByCategory(index: number) {
const item = category.value.list[index]
if (!item)
return
queryParams.value.group = item.name || ''
function handleGetDataByCategory(index : number, cate : IPhotoGroup) {
queryParams.value.group = cate.metadata.name || ''
queryParams.value.page = 1
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
dataList.value = []
category.value.activeIndex = index
handleGetData(true)
}
function handleOnCategoryChange(e: { index: number, name: number }) {
console.log('切换分类', e)
if (lock.value)
return
handleGetDataByCategory(e.index)
}
/* ---------------- 图片预览 ---------------- */
function handlePreview(data : IPhoto) {
@@ -213,25 +195,21 @@ onReachBottom(() => {
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafa;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用图库功能哦请联系管理员"
@on-refresh="handleGetCategory"
/>
<view class="bg-page min-h-screen w-screen flex flex-col pb-6">
<uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用图库功能哦请联系管理员" @on-refresh="handleGetCategory" />
<template v-else>
<!-- 顶部切换 -->
<wd-tabs
v-if="category.list.length > 0"
v-model="category.activeIndex"
align="left"
sticky
:offset-top="0"
@change="handleOnCategoryChange"
>
<wd-tab v-for="cate in category.list" :key="cate.displayName" :title="cate.displayName" />
</wd-tabs>
<wd-sticky>
<scroll-view :scroll-x="true" class="w-full whitespace-nowrap pt-3">
<view v-for="(cate,index) in category.list" :key="cate.spec.displayName"
class="uh-global-card-glass border ml-3 mb-1 px-4 py-1 uh-shadow-xs text-sm rounded-2xl inline-block"
:class="{
'bg-primary text-gray-900 font-bold': index === category.activeIndex,
}" @click="handleGetDataByCategory(index,cate)">
{{ cate.spec.displayName }}({{cate.status?.photoCount??0}})
</view>
</scroll-view>
</wd-sticky>
<!-- 骨架屏 -->
<view v-if="loading === 'loading'" class="loading-wrap box-border p-3">
@@ -239,7 +217,8 @@ onReachBottom(() => {
</view>
<!-- 错误态 -->
<view v-else-if="loading === 'error'" class="error-wrap h-[60vh] w-full flex flex-col items-center justify-center gap-6">
<view v-else-if="loading === 'error'"
class="error-wrap h-[60vh] w-full flex flex-col items-center justify-center gap-6">
<wd-empty description="阿偶,获取数据失败了~" />
<wd-button size="small" plain type="primary" @click="handleGetCategory()">
刷新试试
@@ -247,29 +226,21 @@ onReachBottom(() => {
</view>
<!-- 内容区域 -->
<view v-else class="content box-border w-full p-3">
<view v-if="dataList.length === 0" class="h-[70vh] w-full flex items-center justify-center content-empty">
<view v-else class="box-border w-full p-3">
<view v-if="dataList.length === 0"
class="h-[70vh] w-full flex items-center justify-center content-empty">
<wd-empty description="博主还没有分享图片~" />
</view>
<block v-else>
<!-- 瀑布流(双列) -->
<view class="waterfall flex flex-wrap gap-1.5">
<view
v-for="(item, index) in dataList"
:key="index"
class="waterfall-item h-[250rpx] w-[calc(50%-6rpx)] overflow-hidden rounded-xl"
:class="{ 'is-even mt-3': index % 2 === 1 }"
>
<image
class="waterfall-img h-full w-full"
:src="item.spec.url"
mode="aspectFill"
lazy-load
@click="handlePreview(item)"
/>
<view class="grid grid-cols-2 gap-3">
<view v-for="(item, index) in dataList" :key="index"
class="uh-global-card-glass h-38 w-full overflow-hidden rounded-xl">
<image class="h-full w-full" :src="item.spec.url" mode="aspectFill" lazy-load
@click="handlePreview(item)" />
</view>
</view>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
<view class="load-text w-full py-4 text-center text-xs text-gray-500">
{{ loadMoreText }}
</view>
</block>
@@ -277,16 +248,3 @@ onReachBottom(() => {
</template>
</view>
</template>
<style scoped lang="scss">
.app-page {
display: flex;
flex-direction: column;
}
.error-wrap {
.error-wrap-inner {
/* 无额外样式 */
}
}
</style>
+33 -224
View File
@@ -1,24 +1,18 @@
<script lang="ts" setup>
/**
* 首页(源自旧项目 pages/tabbar/home/home.vue,新建复刻)
* 功能:顶部栏 + 轮播 Banner + 快捷导航 + 精选分类 + 最新文章列表(分页) + 通知弹窗
*/
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getCategoryList, getPostList } from '@/api/halo'
import { getPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
import type { ICategory, IPost } from '@/api/types/halo'
import type { IBannerItem } from '@/components/uh-swiper/uh-swiper.vue'
import type { IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '首页',
enablePullDownRefresh: true,
navigationStyle: 'custom',
backgroundColor: '#F8F8F8',
},
})
@@ -32,7 +26,7 @@ const loading = ref<'loading' | 'success' | 'error'>('loading')
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const articleList = ref<IPost[]>([])
const categoryList = ref<ICategory[]>([])
const result = ref<{ hasNext : boolean }>({ hasNext: false })
const queryParams = ref({
@@ -60,98 +54,13 @@ const bloggerInfo = computed(() => {
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const calcIsShowQuickNavigationEnabled = computed(() => haloConfigs.value.pageConfig?.homeConfig?.useQuickNavigation)
const calcIsShowCategory = computed(() => {
if (calcAuditModeEnabled.value)
return false
return !!haloConfigs.value.pageConfig?.homeConfig?.useCategory
})
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
const bannerConfig = computed(() => haloConfigs.value.pageConfig?.homeConfig?.bannerConfig)
const globalAppSettings = computed(() => settingStore.settings)
/** 快捷导航列表(由配置控制显隐) */
const navList = computed(() => {
const loveEnabled = !!(haloConfigs.value.loveConfig as { loveEnabled?: boolean })?.loveEnabled
const socialEnabled = !!(haloConfigs.value.authorConfig?.social as { enabled?: boolean } | undefined)?.enabled
return [
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
bgColor: 'rgba(3, 169, 244, 0.95)',
icon: 'news',
path: '/pages-blog/archives/archives',
show: true,
},
{
key: 'vote',
title: '投票中心',
bgColor: 'rgba(0, 188, 212, 0.95)',
icon: 'box',
path: '/pages-blog/votes/votes',
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
},
{
key: 'disclaimers',
title: '友情链接',
bgColor: 'rgba(0, 150, 136, 0.95)',
icon: 'link',
path: '/pages-blog/friend-links/friend-links',
show: calcLinksPluginEnabled.value,
},
{
key: 'love',
title: '恋爱日记',
bgColor: 'rgba(255, 76, 103, 0.95)',
icon: 'heart',
path: '/pages-blog/love/love',
show: loveEnabled,
},
{
key: 'contact-blogger',
title: '联系博主',
bgColor: 'rgba(255, 152, 0, 0.95)',
icon: 'message',
path: '/pages-blog/contact/contact',
show: socialEnabled,
},
]
})
/* ---------------- 数据加载 ---------------- */
async function handleQuery() {
// 轮播图数据由 uh-swiper 组件内部请求公开 banners 接口,页面不再组装
await Promise.all([handleGetArticleList(), handleGetCategoryList()])
}
/** 精选分类 */
async function handleGetCategoryList() {
if (calcAuditModeEnabled.value || !calcIsShowCategory.value) {
loading.value = 'success'
return
}
try {
const res = await getCategoryList({ fieldSelector: ['spec.hideFromList=false'], size: 10 })
categoryList.value = res.data.items
.map(item => ({ ...item, postCount: item.postCount ?? 0 }))
.sort((a, b) => (b.postCount || 0) - (a.postCount || 0))
loading.value = 'success'
}
catch (err) {
console.error('获取分类失败', err)
loading.value = 'error'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
handleGetArticleList()
}
/** 文章列表 */
@@ -160,11 +69,12 @@ async function handleGetArticleList() {
// 审核模式:真实文章按 audit-data posts 过滤(数组顺序即展示顺序)
const auditPostNames = appConfigStore.auditData.spec?.posts || []
try {
const res = await getPostList({ page: 1, size: 99999, sort: ['spec.publishTime,desc'] })
const res = await getPostList({ page: 1, size: 0, sort: ['spec.publishTime,desc'] })
const filtered = res.data.items.filter(item => auditPostNames.includes(item.metadata.name))
const orderMap = new Map(auditPostNames.map((name, index) => [name, index]))
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
articleList.value = filtered
articleList.value = filtered.map((item)=>{
item.owner.avatar = checkAvatarUrl(item.owner.avatar);
return item;
})
loading.value = 'success'
loadMoreText.value = t('common.noMore')
}
@@ -188,9 +98,12 @@ async function handleGetArticleList() {
try {
const res = await getPostList({ ...toRaw(queryParams.value) })
result.value.hasNext = res.data.hasNext
articleList.value = isLoadMore.value
articleList.value = (isLoadMore.value
? articleList.value.concat(res.data.items)
: res.data.items
: res.data.items).map((item)=>{
item.owner.avatar = checkAvatarUrl(item.owner.avatar);
return item;
})
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
@@ -207,26 +120,12 @@ async function handleGetArticleList() {
/* ---------------- 跳转 ---------------- */
function handleToArticleDetail(article : IPost) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToCategoryPage() {
uni.switchTab({ url: '/pages/tabbar/category/category' })
}
function handleToCategoryBy(category: ICategory) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
function handleToSearch() {
uni.navigateTo({ url: '/pages-blog/search/search' })
}
@@ -235,10 +134,6 @@ function handleOnLogoToPage() {
uni.switchTab({ url: '/pages/tabbar/about/about' })
}
function handleClickNav(item: { path: string }) {
uni.navigateTo({ url: item.path })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
@@ -249,24 +144,7 @@ function handleToTopPage(duration = 500) {
})
}
function handleOnBannerClick(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
}
// 文章来源条目:用 postId 跳文章详情
const postId = item.postId || String(item.id || '')
if (!postId)
return
handleToArticleDetail({ metadata: { name: postId } } as IPost)
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
@@ -303,111 +181,42 @@ handleQuery()
</script>
<template>
<view class="min-h-screen w-screen flex flex-col">
<!-- 顶部栏 -->
<view class="header flex items-center gap-4 px-3 py-1.5">
<image class="logo h-[60rpx] w-[60rpx] rounded-3xl" :src="appInfo.logo" mode="scaleToFill" @click="handleOnLogoToPage" />
<view class="search-input h-[64rpx] flex flex-1 items-center rounded-3xl bg-[#f5f5f5] px-3" @click="handleToSearch">
<view class="search-icon flex items-center">
<wd-icon name="search" size="16px" color="#999" />
</view>
<text class="search-text text-grey ml-3 text-[26rpx] text-[#999]">搜索内容...</text>
</view>
<!-- #ifdef APP-PLUS || H5 -->
<view class="app-name max-w-[140rpx] overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#666]">
{{ appInfo.name }}
</view>
<!-- #endif -->
</view>
<view class="bg-page min-h-screen w-screen flex flex-col">
<!-- 骨架屏 -->
<view v-if="loading !== 'success' && articleList.length === 0" class="loading-wrap px-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<block v-else>
<!-- 轮播 Banner(数据由 uh-swiper 组件内部请求公开 banners 接口) -->
<view v-if="bannerConfig?.enabled" class="mb-4 bg-white">
<view class="banner mx-3 mt-3 overflow-hidden rounded-xl">
<uh-swiper
:height="bannerConfig.height"
:dot-position="bannerConfig.dotPosition"
:autoplay="true"
:use-dot="bannerConfig.showIndicator"
:use-title="bannerConfig.showTitle"
@on-click="handleOnBannerClick"
/>
</view>
</view>
<!-- 轮播-->
<uh-home-banner />
<!-- 快捷导航 -->
<view v-if="navList.filter(x => x.show).length" class="nav-box overflow-hidden rounded-xl bg-white p-3 px-4">
<view class="page-item-title font-bold">
快捷导航
</view>
<view class="nav-list grid grid-cols-5 mt-6 gap-6">
<template v-for="item in navList.filter(x => x.show)" :key="item.key">
<view class="nav-item flex flex-col items-center gap-3" @click="handleClickNav(item)">
<view class="nav-item-icon h-[88rpx] w-[88rpx] flex items-center justify-center rounded-3xl" :style="{ backgroundColor: item.bgColor }">
<wd-icon :name="item.icon" size="24px" color="#fff" />
</view>
<view class="nav-item-text text-[24rpx] text-[#303133]">
{{ item.title }}
</view>
</view>
</template>
</view>
</view>
<uh-home-quick-nav />
<!-- 精选分类 -->
<block v-if="calcIsShowCategory">
<view class="mb-6 mt-6 flex items-center justify-between px-3">
<view class="page-item-title font-bold">
精选分类
</view>
<view class="show-more flex items-center justify-center rounded-xl bg-white" @click="handleToCategoryPage">
<wd-icon name="arrow-right" size="12px" color="#909399" />
</view>
</view>
<scroll-view class="category mx-6 h-[200rpx] whitespace-nowrap" :scroll-x="true">
<view v-if="categoryList.length === 0" class="cate-empty text-grey h-[180rpx] w-full flex items-center justify-center">
还没有任何分类~
</view>
<view
v-for="category in categoryList"
v-else
:key="category.metadata.name"
class="category-item mr-4 inline-block"
@click="handleToCategoryBy(category)"
>
<uh-category-mini-card :category="category" />
</view>
</scroll-view>
</block>
<uh-home-category />
<!-- 最新文章 -->
<view class="mb-6 mt-6 flex items-center justify-between px-3">
<view class="page-item-title font-bold">
最新列表
</view>
<view class="show-more flex items-center justify-center rounded-xl bg-white" @click="handleToSearch">
<wd-icon name="arrow-right" size="12px" color="#909399" />
</view>
<uh-section-title class="mb-4 px-3 box-border">
最新内容
<template #right>
<view class="uh-global-card-glass flex items-center justify-center rounded-md p-1 text-gray-400"
@click="handleToSearch()">
<wd-icon name="arrow-right" size="12px" />
</view>
</template>
</uh-section-title>
<view v-if="articleList.length === 0" class="article-empty py-10">
<wd-empty description="博主还没有发表任何内容~" />
</view>
<block v-else>
<view :class="globalAppSettings.layout.home">
<uh-article-card
v-for="(article, index) in articleList"
:key="index"
from="home"
:article="article"
@on-click="handleToArticleDetail"
/>
<view class="p-3 pt-0 flex flex-col gap-y-3" :class="globalAppSettings.layout.home">
<uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
@on-click="handleToArticleDetail" />
</view>
<view class="load-text mt-3 pb-5 text-center text-[24rpx] text-[#999]">
<view class="load-text mt-3 pb-5 text-center text-xs text-gray-400">
{{ loadMoreText }}
</view>
<view v-if="articleList.length > 10" class="to-top-btn" @click="handleToTopPage()">
+149 -18
View File
@@ -20,6 +20,8 @@ definePage({
style: {
navigationBarTitleText: '瞬间',
enablePullDownRefresh: true,
// 玻璃拟态试验:下拉/回弹露出的窗口底色对齐壁纸底部色调
backgroundColor: '#f4efff',
},
})
@@ -36,7 +38,11 @@ const bloggerInfo = computed(() => {
}
})
const startConfig = computed(() => haloConfigs.value.appConfig?.startConfig as { title?: string } | undefined)
/** 站点名称(原 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'
@@ -46,7 +52,14 @@ const uniHaloPluginAvailable = ref(true)
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 1 })
const hasNext = ref(false)
const dataList = ref<(IMoment & { images?: { type?: string, url: string }[], videos?: { id?: string, url: string }[], audios?: { type?: string, url: string }[], spec: { newHtml?: string } })[]>([])
/** 列表卡片: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>>({})
@@ -58,18 +71,20 @@ function removeTagLinksCompletely(htmlString: string): string {
return htmlString.replace(regex, '')
}
/** 瞬间项映射(medium 拆分为 images/videos/audios + 内容 tag 清理) */
function mapMomentItem(item: IMoment) {
const medium = (item.spec as unknown as { medium?: { type?: string, url: string }[] }).medium || []
/** 瞬间项映射(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,
owner: {
displayName: bloggerInfo.value.nickname,
avatar: bloggerInfo.value.avatar,
},
newHtml: removeTagLinksCompletely((item.spec as unknown as { content?: { html?: string } }).content?.html || ''),
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() })),
@@ -251,7 +266,15 @@ onReachBottom(() => {
</script>
<template>
<view class="box-border min-h-screen w-screen flex flex-col py-6">
<view class="moments-page relative box-border min-h-screen w-screen flex flex-col py-6">
<!-- 苹果风玻璃拟态试验:fixed 渐变"壁纸"(多层柔光光斑为卡片毛玻璃取色) -->
<view class="moments-wallpaper">
<view class="deco deco-blue" />
<view class="deco deco-pink" />
<view class="deco deco-lavender" />
<view class="deco deco-cyan" />
<view class="deco deco-lift" />
</view>
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
@@ -263,19 +286,19 @@ onReachBottom(() => {
<wd-skeleton :row="3" :animated="true" />
</view>
<view v-else class="flex flex-col gap-y-2 p-4">
<view v-else class="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 in dataList" :key="moment.metadata.name" class="flex flex-col overflow-hidden rounded-xl bg-white shadow-sm">
<!-- 瞬间卡片(玻璃) -->
<view v-for="moment in dataList" :key="moment.metadata.name" class="moment-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 rounded-full" :src="moment.spec.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="nickname ml-3">
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
{{ moment.spec.owner?.displayName || bloggerInfo.nickname }}
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1 text-[24rpx] text-[#666]">
{{ formatMomentTime(moment.spec.releaseTime) }}
@@ -320,7 +343,7 @@ onReachBottom(() => {
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${startConfig?.title || bloggerInfo.nickname}的声音`"
:name="`来自${siteName}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
@@ -349,9 +372,21 @@ onReachBottom(() => {
{{ tag }}
</view>
</view>
<!-- 互动数据(点赞/评论) -->
<view class="flex items-center justify-end gap-7 px-4 pb-4 text-[24rpx] text-[#8a919e]">
<view class="flex items-center gap-1">
<wd-icon name="heart" size="14px" color="#f08585" />
<text>{{ moment.stats.upvote || 0 }}</text>
</view>
<view class="flex items-center gap-1">
<wd-icon name="message" size="14px" color="#9aa3b2" />
<text>{{ moment.stats.totalComment || 0 }}</text>
</view>
</view>
</view>
<view class="to-top-btn fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<view class="fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full moment-glass" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view class="load-text pb-5 text-center text-[24rpx] text-[#999]">
@@ -362,3 +397,99 @@ onReachBottom(() => {
</template>
</view>
</template>
<style scoped lang="scss">
/* 苹果风玻璃拟态试验(测试点:瞬间页)
* 原理:页面固定一层多彩渐变"壁纸",卡片用半透明白 + backdrop-filter,
* 壁纸的颜色透过玻璃才看得见(纯白背景看不出毛玻璃)。
*/
.moments-page {
/* 兜底底色(壁纸固定层异常时页面不至于纯白) */
background-color: #eef1fd;
}
.moments-wallpaper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
/* 通栏渐变铺满整屏(随视口固定):顶部白衔接导航栏,中段淡蓝紫,底部淡粉回环 */
background: linear-gradient(
180deg,
#ffffff 0%,
#f3f6ff 20%,
#edf0ff 46%,
#f6eeff 68%,
#ffeef6 88%,
#f4f7ff 100%
);
}
/* 柔光光斑:以软径向渐变直接呈现"虚化"质感(免 filter blur,低端机零开销),
* 分布覆盖整屏,让玻璃卡片在任何位置都有色可"取" */
.deco {
position: absolute;
border-radius: 50%;
filter: blur(60rpx);
}
.deco-blue {
width: 64%;
height: 64%;
right: -18%;
top: -14%;
background: radial-gradient(circle, rgb(255 255 255 / 85%) 0%, rgb(124 163 255 / 42%) 22%, rgb(96 140 255 / 30%) 42%, transparent 68%);
}
.deco-pink {
width: 48%;
height: 48%;
left: -14%;
top: 16%;
background: radial-gradient(circle, rgb(255 255 255 / 80%) 0%, rgb(255 122 176 / 32%) 26%, rgb(255 110 160 / 20%) 48%, transparent 72%);
}
.deco-lavender {
width: 54%;
height: 54%;
right: -10%;
top: 42%;
background: radial-gradient(circle, rgb(255 255 255 / 75%) 0%, rgb(170 132 255 / 28%) 30%, rgb(158 120 255 / 18%) 50%, transparent 72%);
}
.deco-cyan {
width: 60%;
height: 60%;
left: -16%;
bottom: -18%;
background: radial-gradient(circle, rgb(255 255 255 / 70%) 0%, rgb(90 216 236 / 24%) 30%, rgb(70 200 226 / 16%) 52%, transparent 72%);
}
/* 中部柔和提亮,避免大面积素色发闷 */
.deco-lift {
width: 42%;
height: 42%;
left: 28%;
bottom: 6%;
background: radial-gradient(circle, rgb(255 255 255 / 55%), transparent 70%);
}
.moment-glass {
background-color: rgb(255 255 255 / 55%);
border: 1rpx solid rgb(255 255 255 / 65%);
box-shadow:
inset 0 1rpx 0 rgb(255 255 255 / 75%),
0 8rpx 32rpx rgb(90 105 200 / 14%);
backdrop-filter: blur(24rpx) saturate(160%);
-webkit-backdrop-filter: blur(24rpx) saturate(160%);
/* 低端安卓 WebView 不支持 backdrop-filter 的兜底:提高不透明度保证可读性 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
background-color: rgb(255 255 255 / 88%);
}
}
</style>
+6
View File
@@ -12,6 +12,8 @@ import type { IAppConfig, IAuditDataResult } from '@/api/types/uni-halo'
/** 个人令牌存储 key(与 src/store/token.ts 的 getPersonalToken 保持一致) */
const APP_TOKENS_KEY = 'APP_TOKENS'
/** 合并后配置缓存 key(与 utils/url.ts / api/uni-halo.ts 的 APP_GLOBAL_CONFIGS 读取保持一致) */
const APP_GLOBAL_CONFIGS_KEY = 'APP_GLOBAL_CONFIGS'
export const useAppConfigStore = defineStore(
'appConfig',
@@ -36,6 +38,10 @@ export const useAppConfigStore = defineStore(
if (body) {
configs.value = deepMerge(JSON.parse(JSON.stringify(DefaultAppConfigs)), body)
// 合并结果写入 APP_GLOBAL_CONFIGS 缓存,供 utils/url.ts 图片兜底与
// api/uni-halo.ts 第三方插件授权头等按 storage 路径读取
setCache(APP_GLOBAL_CONFIGS_KEY, configs.value)
// 存储个人令牌(供 getPersonalToken 使用,如非匿名投票)
if (body?.basicConfig?.tokenConfig) {
setCache(APP_TOKENS_KEY, body.basicConfig.tokenConfig)
+54 -5
View File
@@ -1,23 +1,68 @@
/**
* 应用设置 store(源自旧项目 store/setting.js)
* 应用设置 store(两层偏好:站点默认 L0 + 本地差异 L1-L)
*
* - settings:合并结果缓存(= L0 站点默认 + 本地差异,本地优先),persist 持久化供离线兜底;
* - applySiteDefaults(site):启动 fetchConfigs 后把 getConfigs 收集的 L0 与本地差异合并(含旧数据迁移);
* - savePreference(patch):写本地差异(uh_pref_local_v1)并立即重合并;
* - resetPreferences:清本地差异,立即回退「站点默认」(无 L0 时回退内置默认)。
*
* 设计依据:插件仓库 .docs/config-system-v2-redesign.md §3-4(v2.2:远端=站点默认,无用户级远端层)。
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { DefaultAppSettings } from '@/config/appSettings'
import type { IAppSettings } from '@/config/appSettings'
import {
clearLocalPrefs,
mergeWithDefaults,
migrateLegacyLocalPrefs,
readLocalPrefs,
updateLocalPrefs,
} from '@/utils/preference'
import type { LocalPrefs } from '@/utils/preference'
export const useSettingStore = defineStore(
'setting',
() => {
/** 合并结果缓存(站点默认 + 本地差异;页面消费点不变) */
const settings = ref<IAppSettings>(JSON.parse(JSON.stringify(DefaultAppSettings)))
/** 最近一次站点默认(L0,collectSiteDefaults 结果),非持久化 */
const siteDefaults = ref<LocalPrefs | null>(null)
/** 重置为默认设置 */
const updateDefaultAppSettings = () => {
settings.value = JSON.parse(JSON.stringify(DefaultAppSettings))
/** 重算合并结果(统一出口:写 settings 缓存) */
function recompute(): void {
settings.value = mergeWithDefaults(siteDefaults.value, readLocalPrefs())
}
/**
* 应用站点默认并合并本地差异(启动 fetchConfigs 成功后调用;site 为 null/空时仅本地差异覆盖内置默认)
* 首次运行时顺带迁移旧版全量 persist(见 utils/preference.migrateLegacyLocalPrefs)
*/
const applySiteDefaults = (site: LocalPrefs | null): void => {
siteDefaults.value = site && typeof site === 'object' ? site : null
migrateLegacyLocalPrefs()
recompute()
}
/** 保存偏好:写本地差异并立即重合并(改偏好即时生效) */
const savePreference = (patch: LocalPrefs): void => {
updateLocalPrefs(patch)
recompute()
}
/** 重置为站点默认:清本地差异(重置=删除本地,回退 getConfigs 下发值) */
const resetPreferences = (): void => {
clearLocalPrefs()
recompute()
}
/** 兼容旧调用点:恢复默认(新语义=重置回站点默认) */
const updateDefaultAppSettings = (): void => {
resetPreferences()
}
/** 检查并设置默认设置(启动时调用,persist 已保证有值,兜底处理) */
const checkAndSetDefaultAppSettings = () => {
const checkAndSetDefaultAppSettings = (): void => {
if (!settings.value) {
settings.value = JSON.parse(JSON.stringify(DefaultAppSettings))
}
@@ -25,6 +70,10 @@ export const useSettingStore = defineStore(
return {
settings,
siteDefaults,
applySiteDefaults,
savePreference,
resetPreferences,
updateDefaultAppSettings,
checkAndSetDefaultAppSettings,
}
+24 -11
View File
@@ -1,21 +1,34 @@
// 测试用的 iconfont,可生效
// @import './iconfont.css';
.test {
// 可以通过 @apply 多个样式封装整体样式
@apply mt-4 ml-4;
padding-top: 4px;
color: red;
}
@import './iconfont.css';
:root,
page {
// 修改按主题色
// --wot-color-theme: #37c2bc;
--wot-color-theme: #B9E424;
// 修改按钮背景色
// --wot-button-primary-bg-color: green;
--wot-button-primary-bg-color: #B9E424;
}
.uh-global-page {
}
.uh-global-card-glass {
box-sizing: border-box;
background-color: rgb(255 255 255 / 55%);
border: 4rpx solid rgb(255 255 255 / 65%);
box-shadow: inset 0 1rpx 0 rgb(255 255 255 / 75%), 0 8rpx 32rpx rgb(90 105 200 / 14%);
backdrop-filter: blur(24rpx) saturate(160%);
-webkit-backdrop-filter: blur(24rpx) saturate(160%);
/* 低端安卓 WebView 不支持 backdrop-filter 的兜底:提高不透明度保证可读性 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
background-color: rgb(255 255 255 / 88%);
}
}
.uh-shadow-xs {
box-shadow: inset 0 1rpx 0 rgb(255 255 255 / 75%), 0 8rpx 32rpx rgb(90 105 200 / 7%);
}
/*
-12
View File
@@ -1,12 +0,0 @@
/**
* audio 组件默认样式补件
* 说明:@dcloudio/uni-components 3.0.0-4070620250821001 的 style/ 目录未提供 audio.css,
* vite 编译 <audio> 内置组件时按惯例引入该路径会报 Cannot find module。
* 此处以本地文件补齐,并还原旧项目的 .uni-audio-default 定制样式。
*/
/* H5 端 audio 默认控件样式(源自旧项目 moments / moment-detail) */
.uni-audio-default {
width: 100%;
border-radius: 12rpx;
}
-6
View File
@@ -1,6 +0,0 @@
/**
* video 组件默认样式补件
* 说明:@dcloudio/uni-components 3.0.0-4070620250821001 的 style/ 目录未提供 video.css,
* vite 编译 <video> 内置组件时按惯例引入该路径会报 Cannot find module。
* 此处以本地文件补齐(原生 video 控件样式由各平台提供,这里仅占位)。
*/
-10
View File
@@ -1,10 +0,0 @@
{
"pages": [
{
"path": "pages/index/index",
"type": "home",
"style": {}
}
],
"subPackages": []
}
-73
View File
@@ -1,77 +1,4 @@
/* stylelint-disable comment-empty-line-before */
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgb(0 0 0 / 40%); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;
-22
View File
@@ -1,22 +0,0 @@
## 1.1.02023-07-05
优化APP端生成逻辑
## 1.0.92023-07-04
优化
## 1.0.82023-07-04
增加注意事项
## 1.0.72023-07-04
修改本地图片不显示问题
## 1.0.62023-06-26
优化
## 1.0.52023-06-09
增加子集绘制
## 1.0.42023-06-09
增加子集绘制
## 1.0.32023-06-08
增加预览二维码
## 1.0.22023-05-31
增加license
## 1.0.12023-05-30
增加示例
## 1.0.02023-05-30
初始化发布
@@ -1,377 +0,0 @@
<template>
<view class="canvas-main">
<canvas :style="'width:'+width+'rpx;height:'+height+'rpx;'" class="canvas-item" disable-scroll="true"
canvas-id="canvasId" @error="error"></canvas>
</view>
</template>
<script>
export default {
props: {
//画布宽度(rpx)
width: {
type: Number,
default: 750
},
//画布高度(rpx)
height: {
type: Number,
default: 750
},
//生成的图片格式(jpg或png)
fileType: {
type: String,
default: 'png'
},
},
data() {
return {
pixelRatio: 0,
context: null,
canvasList: []
}
},
methods: {
async init(list) {
uni.showLoading({
title: '正在绘制...'
})
if (this.context) {
await this.clear()
this.canvasList = []
this.context = null
}
this.canvasList = JSON.parse(JSON.stringify(list))
const systemInfo = uni.getSystemInfoSync()
this.pixelRatio = systemInfo.pixelRatio
this.context = uni.createCanvasContext('canvasId', this)
this.start()
},
clear() {
return new Promise(async (resolve, reject) => {
await this.context.clearRect(0, 0, this.width, this.height)
resolve()
})
},
async start() {
await Promise.all(this.canvasList.map(async res => {
if (res.type == 'color') {
await this.drawBg(res)
} else if (res.type == 'image') {
await this.drawImage(res)
} else if (res.type == 'text') {
await this.drawText(res)
} else if (res.type == 'line') {
await this.drawLine(res)
}
}))
this.save()
},
drawBg(item) {
return new Promise(async (resolve, reject) => {
item.width = uni.upx2px(item.width)
item.height = uni.upx2px(item.height)
item.x = uni.upx2px(item.x)
item.y = uni.upx2px(item.y)
item.radius = uni.upx2px(item.radius)
item.lineWidth = uni.upx2px(item.lineWidth)
let gradient = ''
if (item.colorObj && item.colorObj.colorList) {
if (item.colorObj.colorList.length == 1) {
this.context.fillStyle = item.colorObj.colorList[0]
} else {
if (item.colorObj.direction == 1) {
gradient = this.context.createLinearGradient(0, 0, item.height, 0)
} else if (item.colorObj.direction == 2) {
gradient = this.context.createLinearGradient(0, 0, 0, item.height)
} else if (item.colorObj.direction == 3) {
gradient = this.context.createLinearGradient(0, 0, item.width, item.height)
} else if (item.colorObj.direction == 4) {
gradient = this.context.createLinearGradient(item.width, 0, 0, item.height)
}
gradient.addColorStop(0, item.colorObj.colorList[0])
gradient.addColorStop(1, item.colorObj.colorList[1])
this.context.fillStyle = gradient
}
} else {
this.context.fillStyle = '#FFFFFF'
}
this.context.save()
if (item.radius > 0) {
this.context.beginPath()
this.context.moveTo(item.x + item.radius, item.y)
this.context.arcTo(item.x + item.width, item.y, item.x + item.width, item.y +
item.radius, item.radius)
this.context.lineTo(item.x + item.width, item.y + item.height - item.radius)
this.context.arcTo(item.x + item.width, item.y + item.height, item.x + item
.width - item.radius, item.y + item.height, item.radius)
this.context.lineTo(item.x + item.radius, item.y + item.height)
this.context.arcTo(item.x, item.y + item.height, item.x, item.y + item
.height - item.radius, item.radius)
this.context.lineTo(item.x, item.y + item.radius)
this.context.arcTo(item.x, item.y, item.x + item.radius, item.y, item.radius)
this.context.closePath()
this.context.clip()
}
this.context.fillRect(item.x, item.y, item.width, item.height)
if (item.lineWidth) {
this.context.setLineDash([])
this.context.lineWidth = item.lineWidth
this.context.strokeStyle = item.lineColor
this.context.beginPath()
this.context.moveTo(item.x + item.radius, item.y)
this.context.arcTo(item.x + item.width, item.y, item.x + item.width, item.y +
item.radius, item.radius)
this.context.lineTo(item.x + item.width, item.y + item.height - item.radius)
this.context.arcTo(item.x + item.width, item.y + item.height, item.x + item
.width - item.radius, item.y + item.height, item.radius)
this.context.lineTo(item.x + item.radius, item.y + item.height)
this.context.arcTo(item.x, item.y + item.height, item.x, item.y + item
.height - item.radius, item.radius)
this.context.lineTo(item.x, item.y + item.radius)
this.context.arcTo(item.x, item.y, item.x + item.radius, item.y, item.radius)
this.context.closePath()
this.context.stroke()
}
this.context.restore()
await this.context.draw(true)
if (item.childs && item.childs.length > 0) {
await Promise.all(item.childs.map(async res => {
if (res.type == 'color') {
await this.drawBg(res)
} else if (res.type == 'image') {
await this.drawImage(res)
} else if (res.type == 'text') {
await this.drawText(res)
} else if (res.type == 'line') {
await this.drawLine(res)
}
}))
}
resolve()
})
},
drawImage(item) {
return new Promise(async (resolve, reject) => {
item.width = uni.upx2px(item.width)
item.height = uni.upx2px(item.height)
item.x = uni.upx2px(item.x)
item.y = uni.upx2px(item.y)
item.radius = uni.upx2px(item.radius)
item.lineWidth = uni.upx2px(item.lineWidth)
await this.getImageInfo(item.path).then(async res => {
this.context.save()
if (item.radius > 0) {
this.context.beginPath()
this.context.moveTo(item.x + item.radius, item.y)
this.context.arcTo(item.x + item.width, item.y, item.x + item.width,
item.y +
item.radius, item.radius)
this.context.lineTo(item.x + item.width, item.y + item.height - item
.radius)
this.context.arcTo(item.x + item.width, item.y + item.height, item.x +
item
.width - item.radius, item.y + item.height, item.radius)
this.context.lineTo(item.x + item.radius, item.y + item.height)
this.context.arcTo(item.x, item.y + item.height, item.x, item.y + item
.height - item.radius, item.radius)
this.context.lineTo(item.x, item.y + item.radius)
this.context.arcTo(item.x, item.y, item.x + item.radius, item.y, item
.radius)
this.context.closePath()
this.context.clip()
}
await this.context.drawImage(res, item.x, item.y, item.width, item
.height)
if (item.lineWidth) {
this.context.setLineDash([])
this.context.lineWidth = item.lineWidth
this.context.strokeStyle = item.lineColor
this.context.beginPath()
this.context.moveTo(item.x + item.radius, item.y)
this.context.arcTo(item.x + item.width, item.y, item.x + item.width,
item.y +
item.radius, item.radius)
this.context.lineTo(item.x + item.width, item.y + item.height - item
.radius)
this.context.arcTo(item.x + item.width, item.y + item.height, item.x +
item
.width - item.radius, item.y + item.height, item.radius)
this.context.lineTo(item.x + item.radius, item.y + item.height)
this.context.arcTo(item.x, item.y + item.height, item.x, item.y + item
.height - item.radius, item.radius)
this.context.lineTo(item.x, item.y + item.radius)
this.context.arcTo(item.x, item.y, item.x + item.radius, item.y, item
.radius)
this.context.closePath()
this.context.stroke()
}
this.context.restore()
await this.context.draw(true)
if (item.childs && item.childs.length > 0) {
await Promise.all(item.childs.map(async res => {
if (res.type == 'color') {
await this.drawBg(res)
} else if (res.type == 'image') {
await this.drawImage(res)
} else if (res.type == 'text') {
await this.drawText(res)
} else if (res.type == 'line') {
await this.drawLine(res)
}
}))
}
resolve()
})
})
},
drawText(item) {
return new Promise(async (resolve, reject) => {
item.width = uni.upx2px(item.width)
item.height = uni.upx2px(item.height)
item.x = uni.upx2px(item.x)
item.y = uni.upx2px(item.y)
item.fontSize = uni.upx2px(item.fontSize)
item.lineHeight = uni.upx2px(item.lineHeight)
await this.drawTextInfo(item.content, item.x, item.y, item.fontSize, item.color, item
.width, item.height, item.lineHeight, item.bold, true)
resolve()
})
},
drawLine(item) {
return new Promise(async (resolve, reject) => {
item.width = uni.upx2px(item.width)
item.startX = uni.upx2px(item.startX)
item.startY = uni.upx2px(item.startY)
item.endX = uni.upx2px(item.endX)
item.endY = uni.upx2px(item.endY)
this.context.setStrokeStyle(item.color)
this.context.setLineWidth(item.width)
this.context.setLineCap('round')
if (item.lineType == 'dash') this.context.setLineDash([item.width * 5, item.width * 5], 0)
else this.context.setLineDash([])
this.context.beginPath()
this.context.moveTo(item.startX, item.startY)
this.context.lineTo(item.endX, item.endY)
this.context.stroke()
this.context.closePath()
resolve()
})
},
drawTextInfo(text, x, y, fontSize, color, width, height, lineHeight, bold, ellipsis) {
return new Promise(async (resolve, reject) => {
this.context.setFillStyle(color)
if (bold) this.context.font = 'bold ' + fontSize + 'px Arial'
else this.context.font = fontSize + 'px Arial'
this.context.setTextBaseline('bottom')
let textArray = text.split('')
let line = ''
let lines = []
for (let i = 0; i < textArray.length; i++) {
let testLine = line + textArray[i]
let testWidth = this.context.measureText(testLine).width
if (testWidth > width) {
lines.push(line)
line = textArray[i]
} else {
line = testLine
}
}
lines.push(line)
let firstWidth = this.context.measureText(lines[0]).width
if (height >= lineHeight * lines.length) {
await Promise.all(lines.map(async (res, i) => {
let lineText = res
let lineHeights = lineHeight * (i + 1)
await this.context.fillText(lineText, x, y + lineHeights)
}))
} else {
let sNum = parseInt(height / lineHeight)
lines = lines.slice(0, sNum)
await Promise.all(lines.map(async (res, i) => {
let lineText = res
let lineHeights = lineHeight * (i + 1)
if (i == lines.length - 1) {
if (this.context.measureText('...').width < fontSize) {
lineText = lineText.substring(0, lineText.length - 1)
lineText += '...'
} else {
lineText = lineText.substring(0, lineText.length - 2)
lineText += '...'
}
}
await this.context.fillText(lineText, x, y + lineHeights)
}))
}
resolve()
})
},
getImageInfo(src) {
return new Promise(async (resolve, reject) => {
if (src.indexOf('http') == -1) {
setTimeout(() => {
resolve(src)
})
} else {
// #ifdef APP-PLUS
uni.getImageInfo({
src: src,
success: (res) => {
resolve(res.path)
},
fail(err) {
resolve(src)
}
})
// #endif
// #ifndef APP-PLUS
uni.downloadFile({
url: src,
success: (res) => {
if (res.statusCode === 200) resolve(res.tempFilePath)
},
fail: (err) => {
resolve(src)
}
})
// #endif
}
})
},
save() {
let timer = setTimeout(async () => {
await this.context.draw(true, setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: 'canvasId',
fileType: this.fileType,
quality: 1,
width: this.width,
height: this.height,
destWidth: this.width * this.pixelRatio,
destHeight: this.height * this.pixelRatio,
success: (res) => {
uni.hideLoading()
this.$emit('change', res.tempFilePath)
},
fail: (err) => {
console.log('生成图片失败:', err)
}
}, this)
}, 500))
clearTimeout(timer)
}, 500)
},
error(e) {
console.log('错误信息:', e)
}
}
}
</script>
</script>
<style lang="scss" scoped>
.canvas-main {
position: fixed;
z-index: -999999 !important;
opacity: 0;
top: -5000rpx;
}
</style>
-6
View File
@@ -1,6 +0,0 @@
### 1、本插件可免费下载使用;
### 2、未经许可,严禁复制本插件派生同类插件上传插件市场;
### 3、未经许可,严禁在插件市场恶意复制抄袭本插件进行违规获利;
### 4、对本软件的任何使用都必须遵守这些条款,违反这些条款的个人或组织将面临法律追究。
-100
View File
@@ -1,100 +0,0 @@
{
"id": "liu-poster",
"displayName": "canvas海报画板、海报生成、海报图",
"version": "1.1.0",
"description": "canvas海报画板、海报生成、海报图组件,配置简单,支持绘制背景色、绘制图片、绘制文本、绘制线条,自由生成海报图片",
"keywords": [
"海报",
"生成海报",
"canvas",
"图片合成",
"图片处理"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0",
"uni-app": "^3.1.0",
"uni-app-x": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "",
"darkmode": "-",
"i18n": "-",
"widescreen": "-"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√"
},
"client": {
"uni-app": {
"vue": {
"vue2": "-",
"vue3": "-"
},
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"vue": "-",
"nvue": "-",
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-",
"alipay": "-",
"toutiao": "-",
"baidu": "-",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "-",
"lark": "-"
},
"quickapp": {
"huawei": "-",
"union": "-"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}
-260
View File
@@ -1,260 +0,0 @@
# liu-poster适用于uni-app项目的canvas海报画板、海报生成、海报图组件
### 本组件目前兼容微信小程序、H5
### 本组件是canvas海报画板、海报生成、海报图组件,配置简单,支持绘制背景色、绘制图片、绘制文本、绘制线条,自由生成海报图片
# --- 扫码预览、关注我们 ---
## 扫码关注公众号,查看更多插件信息,预览插件效果!
![](https://uni.ckapi.pro/uniapp/publicize.png)
### 属性说明
| 名称 | 类型 | 默认值 | 描述 |
| ----------------------------|--------------- | -------------------- | ---------------|
| width | Number | 750 | 画布宽度(rpx)
| height | Number | 750 | 画布高度(rpx)
| fileType | String | png | 生成的图片格式(jpg或png)
| @change | Function | | 海报绘制成功回调事件
### 使用示例
```
<template>
<view class="tab-box">
<view class="btn-complete" @click="open">一键生成海报</view>
<liu-poster ref="liuPoster" :width="750" :height="1300" @change="change"></liu-poster>
<image class="success-img" :src="url" @click="previewImg(url)"></image>
</view>
</template>
<script>
export default {
data() {
return {
canvasList: [{
type: 'color', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 750, //宽度(rpx)
height: 1300, //高度(rpx)
x: 0, //x轴位置(离左边的距离rpx)
y: 0, //y轴位置(离上边的距离rpx)
radius: 100, //圆角(rpx)
lineWidth: 40, //边框宽度(rpx)
lineColor: '#000000', //边框颜色
colorObj: {
colorList: ['#6900FF', '#FFFFFF'], //传入1个值为纯色,2个值为渐变色
direction: 2 //渐变色绘制方向(1:从左到右;2:从上到下;3:左上角到右下角;4:右上角到左下角)
}, //type为color时必填
}, {
type: 'image', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 132, //宽度(rpx)
height: 132, //高度(rpx)
x: 40, //x轴位置(离左边的距离rpx)
y: 120, //y轴位置(离上边的距离rpx)
radius: 66, //圆角(rpx)
lineWidth: 6, //边框宽度(rpx)
lineColor: '#FFFFFF', //边框颜色
path: 'https://img1.baidu.com/it/u=1471990434,2209509794&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', //图片地址(type为image时必填)
}, {
type: 'text', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 400, //文本宽度(rpx)
height: 40, //文本高度(rpx)
x: 200, //x轴位置(离左边的距离rpx)
y: 145, //y轴位置(离上边的距离rpx)
color: '#FFFFFF', //文本颜色
fontSize: 36, //文字大小(rpx)
lineHeight: 36, //文字行高(rpx)
bold: true, //文字是否加粗
content: '好物分享猫猫虫', //文本内容(type为text时必填)
}, {
type: 'text', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 400, //文本宽度(rpx)
height: 40, //文本高度(rpx)
x: 200, //x轴位置(离左边的距离rpx)
y: 195, //y轴位置(离上边的距离rpx)
color: '#FFFFFF', //文本颜色
fontSize: 28, //文字大小(rpx)
lineHeight: 28, //文字行高(rpx)
bold: false, //文字是否加粗
content: '猫猫虫给你分享了一张美图', //文本内容(type为text时必填)
}, {
type: 'image', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 670, //宽度(rpx)
height: 670, //高度(rpx)
x: 40, //x轴位置(离左边的距离rpx)
y: 300, //y轴位置(离上边的距离rpx)
radius: 20, //圆角(rpx)
lineWidth: 12, //边框宽度(rpx)
lineColor: '#FFFFFF', //边框颜色
path: 'https://img1.baidu.com/it/u=1471990434,2209509794&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', //图片地址(type为image时必填)
childs: [{
type: 'text', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 400, //文本宽度(rpx)
height: 40, //文本高度(rpx)
x: 100, //x轴位置(离左边的距离rpx)
y: 400, //y轴位置(离上边的距离rpx)
color: '#FFFFFF', //文本颜色
fontSize: 36, //文字大小(rpx)
lineHeight: 36, //文字行高(rpx)
bold: true, //文字是否加粗
content: '好物分享猫猫虫', //文本内容(type为text时必填)
}]
}, {
type: 'line', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 4, //线条宽度(rpx)
color: '#FFFFFF', //线条颜色
startX: 20, //起点x轴位置(离左边的距离rpx)
startY: 270, //起点y轴位置(离上边的距离rpx)
endX: 730, //终点x轴位置(离左边的距离rpx)
endY: 270, //终点y轴位置(离上边的距离rpx)
lineType: 'dash', //线条类型(solid:实线;dash:虚线)
}, {
type: 'line', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 4, //线条宽度(rpx)
color: '#FFFFFF', //线条颜色
startX: 20, //起点x轴位置(离左边的距离rpx)
startY: 1000, //起点y轴位置(离上边的距离rpx)
endX: 730, //终点x轴位置(离左边的距离rpx)
endY: 1000, //终点y轴位置(离上边的距离rpx)
lineType: 'dash', //线条类型(solid:实线;dash:虚线)
}, {
type: 'text', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 500, //文本宽度(rpx)
height: 150, //文本高度(rpx)
x: 40, //x轴位置(离左边的距离rpx)
y: 1050, //y轴位置(离上边的距离rpx)
color: '#9043FD', //文本颜色
fontSize: 32, //文字大小(rpx)
lineHeight: 45, //文字行高(rpx)
bold: true, //文字是否加粗
content: '这个是一段测试文字,这个是一段测试文字,这个是一段测试文字,这个是一段测试文字,这个是一段测试文字。', //文本内容(type为text时必填)
}, {
type: 'image', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 150, //宽度(rpx)
height: 150, //高度(rpx)
x: 550, //x轴位置(离左边的距离rpx)
y: 1050, //y轴位置(离上边的距离rpx)
radius: 4, //圆角(rpx)
lineWidth: 6, //边框宽度(rpx)
lineColor: '#FFFFFF', //边框颜色
path: 'https://img1.baidu.com/it/u=1471990434,2209509794&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', //图片地址(type为image时必填)
}],
url: ''
};
},
methods: {
//开始绘制
open() {
this.$nextTick(() => {
this.$refs.liuPoster.init(this.canvasList)
})
},
//绘制成功返回生成的海报图片地址
change(e) {
this.url = e
},
//预览生成的海报图片
previewImg(url) {
if (!url) return
uni.previewImage({
urls: [url]
})
}
}
};
</script>
<style lang="scss" scoped>
.tab-box {
width: 100%;
height: 100vh;
box-sizing: border-box;
background-color: #f0f0f0;
padding-top: 20rpx;
.btn-reset {
width: 100%;
height: 72rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #FD430E;
font-size: 30rpx;
color: #3E3E3E;
display: flex;
align-items: center;
justify-content: center;
}
.btn-complete {
width: 98%;
height: 76rpx;
border-radius: 40rpx;
font-size: 30rpx;
color: #FFFFFF;
background-color: #FD430E;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}
.success-img {
width: 100%;
height: 1300rpx;
margin-top: 20rpx;
}
}
</style>
```
### 传入的canvasList参数说明
### 绘制类型有4种:color:背景色;image:图片;text:文字;line:线条
```
canvasList: [{
type: 'color', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 750, //宽度(rpx)
height: 1500, //高度(rpx)
x: 0, //x轴位置(离左边的距离rpx)
y: 0, //y轴位置(离上边的距离rpx)
radius: 100, //圆角(rpx)
lineWidth: 40, //边框宽度(rpx)
lineColor: '#000000', //边框颜色
colorObj: {
colorList: ['#6900FF', '#FFFFFF'], //传入1个值为纯色,2个值为渐变色
direction: 2 //渐变色绘制方向(1:从左到右;2:从上到下;3:左上角到右下角;4:右上角到左下角)
}, //type为color时必填
childs:[],//在背景色上绘制的内容放在childs里面即可
}, {
type: 'image', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 132, //宽度(rpx)
height: 132, //高度(rpx)
x: 40, //x轴位置(离左边的距离rpx)
y: 150, //y轴位置(离上边的距离rpx)
radius: 66, //圆角(rpx)
lineWidth: 2, //边框宽度(rpx)
lineColor: '#FFFFFF', //边框颜色
path: 'https://img1.baidu.com/it/u=1471990434,2209509794&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', //图片地址(type为image时必填)
childs:[],//如果在图片上绘制其他内容则将要绘制的内容放在childs里面即可
}, {
type: 'text', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 400, //文本宽度(rpx)
height: 100, //文本高度(rpx)
x: 200, //x轴位置(离左边的距离rpx)
y: 170, //y轴位置(离上边的距离rpx)
color: '#FFFFFF', //文本颜色
fontSize: 36, //文字大小(rpx)
lineHeight: 45, //文字行高(rpx)
bold: true, //文字是否加粗
content: '好物分享猫猫虫好物分享猫猫虫好物分享猫猫虫好物分享猫猫虫好物分享猫猫虫好物分享猫猫虫好物分享猫猫虫', //文本内容(type为text时必填)
}, {
type: 'line', //绘制类型(color:背景色;image:图片;text:文字;line:线条),
width: 2, //线条宽度(rpx)
color: '#FFFFFF', //线条颜色
startX: 0, //起点x轴位置(离左边的距离rpx)
startY: 310, //起点y轴位置(离上边的距离rpx)
endX: 750, //终点x轴位置(离左边的距离rpx)
endY: 310, //终点y轴位置(离上边的距离rpx)
lineType: 'dash', //线条类型(solid:实线;dash:虚线)
}]
```
### 注意
# 1、H5端使用网络图片需要解决跨域问题;
# 2、小程序使用网络图片需要在微信公众平台配置downloadFile合法域名。
+3 -2
View File
@@ -8,12 +8,13 @@ import { useAppConfigStore } from '@/store/appConfig'
let aniWaitIndex = 0
/**
* 设置页面标题(默认取应用配置 startConfig.title)
* 设置页面标题(默认取应用配置 appInfo.name,原 startConfig 已随启动页下线)
* @param title 标题,为空时回退 uni-halo
*/
export function handleSetPageTitle(title?: string) {
const appConfigStore = useAppConfigStore()
const fallbackTitle = (appConfigStore.configs.appConfig as { startConfig?: { title?: string } } | undefined)?.startConfig?.title || 'uni-halo'
const fallbackTitle = ((appConfigStore.configs.appConfig as { appInfo?: { name?: string } } | undefined)
?.appInfo?.name) || 'uni-halo'
uni.setNavigationBarTitle({
title: title || fallbackTitle,
})
+193
View File
@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DefaultAppSettings } from '@/config/appSettings'
import type { IAppSettings } from '@/config/appSettings'
import {
LOCAL_PREFS_KEY,
clearLocalPrefs,
collectSiteDefaults,
isLocalOverride,
mergeWithDefaults,
migrateLegacyLocalPrefs,
readLocalPrefs,
updateLocalPrefs,
} from './preference'
/** 内存版 uni storage(与 utils/storage 的 {data,time,expire} 包装配合) */
const mem = new Map<string, string>()
function setupUniStorageMock() {
vi.mocked(uni.getStorageSync).mockImplementation((key: string) => mem.get(key) ?? null)
vi.mocked(uni.setStorageSync).mockImplementation((key: string, val: unknown) => {
mem.set(key, val as string)
})
vi.mocked(uni.removeStorageSync).mockImplementation((key: string) => {
mem.delete(key)
})
}
describe('preference 基础读写', () => {
beforeEach(() => {
mem.clear()
setupUniStorageMock()
})
it('readLocalPrefs:无数据时返回空对象', () => {
expect(readLocalPrefs()).toEqual({})
})
it('updateLocalPrefs:嵌套字段增量合并', () => {
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
updateLocalPrefs({ layout: { cardType: 'tb_image_text' } })
expect(readLocalPrefs()).toEqual({
layout: { home: 'h_row_col2', cardType: 'tb_image_text' },
})
})
it('updateLocalPrefsnull 删除该键(回退跟随站点默认)', () => {
updateLocalPrefs({ layout: { home: 'h_row_col2', cardType: 'tb_image_text' } })
updateLocalPrefs({ layout: { home: null } })
expect(readLocalPrefs()).toEqual({ layout: { cardType: 'tb_image_text' } })
})
it('updateLocalPrefs(null):整体清空差异', () => {
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
updateLocalPrefs(null)
expect(readLocalPrefs()).toEqual({})
expect(mem.has(LOCAL_PREFS_KEY)).toBe(false)
})
it('clearLocalPrefs:删除存储键', () => {
updateLocalPrefs({ layout: { home: 'h_row_col2' } })
clearLocalPrefs()
expect(readLocalPrefs()).toEqual({})
})
it('isLocalOverride:按路径判断是否被本地覆盖', () => {
updateLocalPrefs({ gallery: { useWaterfull: false } })
expect(isLocalOverride(readLocalPrefs(), ['gallery', 'useWaterfull'])).toBe(true)
expect(isLocalOverride(readLocalPrefs(), ['banner', 'useDot'])).toBe(false)
})
})
describe('mergeWithDefaults / collectSiteDefaults', () => {
it('默认值兜底:无站点默认无本地差异时等于内置默认', () => {
expect(mergeWithDefaults()).toEqual(DefaultAppSettings)
})
it('本地优先于站点默认,站点默认优先于内置默认', () => {
const merged = mergeWithDefaults(
{ layout: { home: 'h_row_col1' }, gallery: { useWaterfull: true } },
{ layout: { home: 'h_row_col2' } },
)
expect(merged.layout.home).toBe('h_row_col2')
expect(merged.gallery.useWaterfull).toBe(true)
expect(merged.isAvatarRadius).toBe(DefaultAppSettings.isAvatarRadius)
})
it('collectSiteDefaultsbanner 站点默认映射(showIndicator→useDot)', () => {
const site = collectSiteDefaults({
pageConfig: {
homeConfig: {
bannerConfig: { showIndicator: false, dotPosition: 'bottom' },
},
},
})
expect(site.banner).toEqual({ useDot: false, dotPosition: 'bottom' })
})
it('collectSiteDefaultspreferences(L0)映射到 layout.home/cardType/isAvatarRadius', () => {
const site = collectSiteDefaults({
preferences: {
homeListLayout: 'h_row_col2',
articleCardType: 'tb_image_text',
avatarRadius: true,
},
})
expect(site.layout).toEqual({ home: 'h_row_col2', cardType: 'tb_image_text' })
expect(site.isAvatarRadius).toBe(true)
})
it('preferences L0 参与合并,本地未覆盖时跟随站点默认', () => {
const site = collectSiteDefaults({
preferences: {
homeListLayout: 'h_row_col2',
articleCardType: 'tb_image_text',
avatarRadius: true,
},
})
const merged = mergeWithDefaults(site, {})
expect(merged.layout.home).toBe('h_row_col2')
expect(merged.layout.cardType).toBe('tb_image_text')
expect(merged.isAvatarRadius).toBe(true)
})
it('preferences L0 可被本地差异覆盖,重置后回退站点默认', () => {
const site = collectSiteDefaults({
preferences: {
homeListLayout: 'h_row_col2',
articleCardType: 'tb_image_text',
avatarRadius: true,
},
})
const merged = mergeWithDefaults(site, { layout: { home: 'h_row_col1' }, isAvatarRadius: false })
expect(merged.layout.home).toBe('h_row_col1')
expect(merged.layout.cardType).toBe('tb_image_text')
expect(merged.isAvatarRadius).toBe(false)
})
it('站点 banner 默认参与合并,本地未覆盖时跟随站点默认', () => {
const site = collectSiteDefaults({
pageConfig: {
homeConfig: {
bannerConfig: { showIndicator: false, dotPosition: 'bottom' },
},
},
})
const merged = mergeWithDefaults(site, {})
expect(merged.banner.useDot).toBe(false)
expect(merged.banner.dotPosition).toBe('bottom')
expect(merged.layout.home).toBe(DefaultAppSettings.layout.home)
})
it('未知枚举值不回退抛错(跟随默认)', () => {
const merged = mergeWithDefaults({}, { layout: { home: 'not-exist' } })
expect(merged.layout.home).toBe('not-exist')
})
})
describe('migrateLegacyLocalPrefs', () => {
beforeEach(() => {
mem.clear()
setupUniStorageMock()
})
it('旧 persist 存在时仅迁移被改过的叶子字段', () => {
const legacySettings: IAppSettings = JSON.parse(JSON.stringify(DefaultAppSettings))
legacySettings.layout.home = 'h_row_col2'
legacySettings.gallery.useWaterfull = false
mem.set('setting', JSON.stringify({ settings: legacySettings }))
expect(migrateLegacyLocalPrefs()).toBe(true)
expect(readLocalPrefs()).toEqual({
layout: { home: 'h_row_col2' },
gallery: { useWaterfull: false },
})
})
it('已存在新差异键时不再重复迁移', () => {
updateLocalPrefs({ layout: { home: 'h_row_col1' } })
const legacySettings = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings
legacySettings.layout.home = 'h_row_col2'
mem.set('setting', JSON.stringify({ settings: legacySettings }))
expect(migrateLegacyLocalPrefs()).toBe(false)
expect(readLocalPrefs()).toEqual({ layout: { home: 'h_row_col1' } })
})
it('无旧键或格式异常时返回 false 且不写新键', () => {
expect(migrateLegacyLocalPrefs()).toBe(false)
mem.set('setting', 'not-json{')
expect(migrateLegacyLocalPrefs()).toBe(false)
expect(mem.has(LOCAL_PREFS_KEY)).toBe(false)
})
})
+221
View File
@@ -0,0 +1,221 @@
/**
* 用户偏好「两层」基建(设计见插件仓库 .docs/config-system-v2-redesign.md §3-4)
*
* 两层语义:
* - L0 站点默认:插件 getConfigs 下发(本模块把 getConfigs 中与偏好相关的字段收集为 Partial<IAppSettings>);
* - L1-L 本地差异:storage 键 `uh_pref_local_v1`,只存与站点默认不同的字段(字段级覆盖,本地优先),
* 值缺省/被删除即回退跟随站点默认;重置 = 删除本地差异。
*
* 读取优先级:本地差异 > 站点默认(L0) > 客户端内置默认(DefaultAppSettings)。
*/
import { DefaultAppSettings } from '@/config/appSettings'
import type { IAppSettings } from '@/config/appSettings'
import type { IAppConfig } from '@/api/types/uni-halo'
import { delCache, getCache, setCache } from '@/utils/storage'
/** 本地偏好差异存储 key(仅差异 JSON) */
export const LOCAL_PREFS_KEY = 'uh_pref_local_v1'
/** 旧版 setting store persist key(pinia-plugin-persistedstate 默认以 store id 为 key) */
export const LEGACY_SETTINGS_KEY = 'setting'
/** 深层可选类型(仅覆盖部分字段的差异) */
export type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
/** 本地偏好差异(只放与站点默认不同的字段) */
export type LocalPrefs = DeepPartial<IAppSettings>
/** 读取本地偏好差异(无则返回空对象) */
export function readLocalPrefs(): LocalPrefs {
const prefs = getCache<LocalPrefs>(LOCAL_PREFS_KEY)
return prefs && typeof prefs === 'object' ? prefs : {}
}
/** 整份覆盖写本地偏好差异(一般由 updateLocalPrefs 内部使用) */
function writeLocalPrefs(prefs: LocalPrefs): void {
setCache(LOCAL_PREFS_KEY, prefs)
}
/**
* 增量更新本地偏好差异(null 表示删除该键、回退跟随站点默认)
* @param patch 仅包含被覆盖字段的差异;嵌套对象按 key 递归合并
*/
export function updateLocalPrefs(patch: LocalPrefs | null): void {
if (patch === null) {
clearLocalPrefs()
return
}
writeLocalPrefs(mergePrefs(readLocalPrefs(), patch))
}
/** 删除本地偏好差异(重置 = 清本地,回退站点默认) */
export function clearLocalPrefs(): void {
delCache(LOCAL_PREFS_KEY)
}
/**
* 把 L0 站点默认(getConfigs 下发值)中与偏好相关的字段收集为本地差异形状的站点默认。
* 偏好字段与 getConfigs 字段不完全同名,此处维护映射表:
* - preferences.homeListLayout / articleCardType / avatarRadius(插件「通用配置-偏好设置」
* 分区,L0 additive 顶层键)→ layout.home / layout.cardType / isAvatarRadius;
* - pageConfig.homeConfig.bannerConfig → banner.useDot / dotPosition。
*/
export function collectSiteDefaults(configs: Partial<IAppConfig>): LocalPrefs {
const result: LocalPrefs = {}
// 站点级展示偏好默认(L0,GeneralConfig.preferences,2026-09-02 插件端新增)
const preferences = configs.preferences
if (preferences && typeof preferences === 'object') {
if (preferences.homeListLayout) {
result.layout = { ...result.layout, home: preferences.homeListLayout }
}
if (preferences.articleCardType) {
result.layout = { ...result.layout, cardType: preferences.articleCardType }
}
if (typeof preferences.avatarRadius === 'boolean') {
result.isAvatarRadius = preferences.avatarRadius
}
}
// 轮播渲染参数(L0):站点「显示指示器」→ 本地偏好 banner.useDot
const bannerConfig = configs.pageConfig?.homeConfig?.bannerConfig
if (bannerConfig && typeof bannerConfig === 'object') {
result.banner = {
useDot: bannerConfig.showIndicator,
dotPosition: bannerConfig.dotPosition,
}
}
// 预留:图库瀑布流 L0(galleryConfig.useWaterfall)随二期下发后在此补充
return result
}
/**
* 偏好解析合并:本地差异 > 站点默认 > 内置默认。
* @param siteDefaults collectSiteDefaults 的结果(可空)
* @param localPrefs readLocalPrefs 的结果(可空)
*/
export function mergeWithDefaults(
siteDefaults?: LocalPrefs | null,
localPrefs?: LocalPrefs | null,
): IAppSettings {
const base = JSON.parse(JSON.stringify(DefaultAppSettings)) as IAppSettings
const site = siteDefaults && typeof siteDefaults === 'object' ? siteDefaults : {}
const local = localPrefs && typeof localPrefs === 'object' ? localPrefs : {}
// base 已含完整默认结构,合并结果必然满足 IAppSettings
return mergePrefs(mergePrefs(base, site), local) as IAppSettings
}
/**
* 递归合并:target 为底,source 覆盖;source 中值为 null/undefined 的键删除(null 语义=跟随默认)
* 数组等引用类型直接替换。对象用结构化克隆保证合并结果与 DefaultAppSettings 结构一致。
*/
function mergePrefs<T>(target: T, source: T): T {
if (!isObject(source)) {
return target
}
const output: Record<string, unknown> = isObject(target)
? { ...(target as Record<string, unknown>) }
: {}
const targetRecord = (target ?? {}) as Record<string, unknown>
Object.keys(source as Record<string, unknown>).forEach((key) => {
const sourceValue = (source as Record<string, unknown>)[key]
if (sourceValue === null || sourceValue === undefined) {
delete output[key]
return
}
const targetValue = targetRecord[key]
if (isObject(targetValue) && isObject(sourceValue)) {
output[key] = mergePrefs(targetValue, sourceValue)
}
else {
output[key] = sourceValue
}
})
return output as T
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* 旧版迁移:把旧 setting store persist 的全量本地设置(无 L0 时代)转为差异。
* 规则:与 DefaultAppSettings 相同的键视为未覆盖(丢弃),不同键写入 uh_pref_local_v1。
* 仅当新差异键不存在且旧键存在时执行一次;返回是否发生迁移。
*/
export function migrateLegacyLocalPrefs(): boolean {
if (getCache<LocalPrefs>(LOCAL_PREFS_KEY)) {
return false
}
const raw = uni.getStorageSync(LEGACY_SETTINGS_KEY)
if (!raw) {
return false
}
let legacy: { settings?: IAppSettings } | null = null
try {
legacy = typeof raw === 'string' ? JSON.parse(raw) : raw
}
catch {
return false
}
const settings = legacy?.settings
if (!settings || typeof settings !== 'object') {
return false
}
const diff = diffFromDefaults(settings)
writeLocalPrefs(diff)
return true
}
/** 计算 settings 与 DefaultAppSettings 的差异(仅保留被用户改过的叶子字段) */
function diffFromDefaults(settings: IAppSettings): LocalPrefs {
const diff: Record<string, unknown> = {}
const defaultsRecord = DefaultAppSettings as unknown as Record<string, unknown>
const settingsRecord = settings as unknown as Record<string, unknown>
Object.keys(defaultsRecord).forEach((key) => {
const defaultItem = defaultsRecord[key]
const settingItem = settingsRecord[key]
if (isObject(defaultItem) && isObject(settingItem)) {
// 只保留与 default 不一致的嵌套键
const result: Record<string, unknown> = {}
Object.keys(defaultItem).forEach((nestedKey) => {
const dv = defaultItem[nestedKey]
const sv = settingItem[nestedKey]
if (isObject(dv) && isObject(sv)) {
const deep = diffFromDefaults({ ...dv, ...sv } as unknown as IAppSettings)
if (Object.keys(deep).length > 0) {
result[nestedKey] = deep
}
}
else if (sv !== dv) {
result[nestedKey] = sv
}
})
if (Object.keys(result).length > 0) {
diff[key] = result
}
}
else if (settingItem !== undefined && settingItem !== defaultItem) {
diff[key] = settingItem
}
})
return diff as LocalPrefs
}
/** 判断某字段当前是否被本地差异覆盖(供设置页三态展示) */
export function isLocalOverride(localPrefs: LocalPrefs, path: string[]): boolean {
let cursor: unknown = localPrefs
for (const key of path) {
if (cursor === null || cursor === undefined) {
return false
}
cursor = (cursor as Record<string, unknown>)[key]
if (cursor === undefined) {
return false
}
}
return true
}
+3 -1
View File
@@ -94,7 +94,9 @@ export default defineConfig({
theme: {
colors: {
/** 主题色,用法如: text-primary */
primary: 'var(--wot-color-theme,#0957DE)',
primary: 'var(--wot-color-theme,#B9E424)',
secondary: 'var(--wot-color-secondary,#D7F94C)',
page: 'var(--wot-color-page,#f6f3ee)',
},
fontSize: {
/** 提供更小号的字体,用法如:text-2xs */