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

chore: 批量新增项目依赖、工具函数、页面与组件资源

1. 新增mp-html、qs等生产依赖,补全项目基础库
2. 新增平台判断、缓存、工具函数等通用工具集
3. 新增标签页、网站浏览页、关于页等业务页面
4. 新增分类卡片、通知弹窗等业务组件
5. 新增uts-progressNotification、liu-poster、uhalo-upgrade等uni模块
6. 补充audio/video组件样式补件,修复uni-components路径缺失问题
7. 新增环境变量Halo个人令牌配置项
8. 重构store导出结构,新增appConfig/halo/setting三个状态模块
9. 新增tsconfig编译目标配置,适配更高版本ES语法
This commit is contained in:
小莫唐尼
2026-08-31 19:56:16 +08:00
parent ba5b77568b
commit 067f3ed98a
147 changed files with 20866 additions and 325 deletions
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts" setup>
/**
* 关于项目页(源自旧项目 pagesA/about,新建复刻)
*/
import { onLoad } from '@dcloudio/uni-app'
definePage({
style: {
navigationBarTitleText: '关于项目',
},
})
const links = [
{ title: '开源组织', value: '巷子工坊', copy: 'https://www.ialley.cn', tip: '巷子工坊官网已复制成功!' },
{ title: '开源作者', value: '小莫唐尼', copy: 'https://www.xiaoxiaomo.cn', tip: '作者主页地址已复制' },
{ title: '作者博客', value: 'https://blog.xiaoxiaomo.cn', copy: 'https://blog.xiaoxiaomo.cn', tip: '作者博客地址已复制' },
{ title: '文档地址', value: 'https://uni-halo.925i.cn', copy: 'https://uni-halo.925i.cn', tip: '项目码云仓库已复制' },
{ title: '码云仓库', value: 'https://gitee.com/ialley-workshop-open/uni-halo', copy: 'https://gitee.com/ialley-workshop-open/uni-halo', tip: '码云仓库地址已复制' },
{ title: 'Github', value: 'https://github.com/ialley-workshop-open/uni-halo', copy: 'https://github.com/ialley-workshop-open/uni-halo', tip: 'Github地址已复制' },
]
function copyText(content: string, tips: string) {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
onLoad(() => {
uni.setNavigationBarTitle({ title: '关于项目' })
})
</script>
<template>
<view class="app-page box-border h-screen w-screen flex flex-col items-center bg-white pt-10">
<view class="logo mt-10 pt-10">
<image class="logo-img h-[160rpx] w-[160rpx] rounded-xl" src="https://uni-halo.925i.cn/logo.png" mode="aspectFill" />
</view>
<view class="mt-3 text-[36rpx] font-bold">
uni-halo
</view>
<view class="list-group mt-12 w-full">
<view
v-for="link in links"
:key="link.title"
class="list-item flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7"
@click="copyText(link.copy, link.tip)"
>
<text class="list-title w-[160rpx] shrink-0 text-[28rpx] text-[#303133]">{{ link.title }}</text>
<text class="list-value max-w-[480rpx] overflow-hidden text-ellipsis whitespace-nowrap text-right text-[24rpx] text-[#909399]">{{ link.value }}</text>
</view>
</view>
<view class="copyright fixed bottom-0 left-0 box-border w-screen bg-white p-9 text-center text-[22rpx] text-[#909399]">
<view>根据 AGPL-3.0 协议开源</view>
<view class="mt-2">
2022 uni-halo 开源项目丨巷子工坊@小莫唐尼
</view>
</view>
</view>
</template>
+380
View File
@@ -0,0 +1,380 @@
<script lang="ts" setup>
/**
* 归档页(源自旧项目 pagesA/archives,新建复刻)
* 按月份/年份分组展示文章时间线
*/
import { computed, ref } from 'vue'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkThumbnailUrl } from '@/utils/url'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '归档',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const calcAuditModeEnabled = computed(() => !!appConfigStore.configs.auditConfig?.auditModeEnabled)
const mockJson = computed(() => appConfigStore.mockJson)
const globalAppSettings = computed(() => settingStore.settings)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const tab = ref({ activeIndex: 0, list: ['按月份查看', '按年份查看'] })
const queryParams = ref({ size: 10, page: 1 })
const result = ref<{ hasNext: boolean }>({ hasNext: false })
const cacheDataList = ref<IPost[]>([])
const dataList = ref<{
sort: number
key: string
year: string
month: string
posts: IPost[]
}[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref('加载中...')
const postLabelYearKey = 'content.halo.run/archive-year'
const postLabelMonthKey = 'content.halo.run/archive-month'
/* ---------------- 数据处理 ---------------- */
/** 按 tab 分组文章 */
function handleGetPosts(list: IPost[]): Record<string, IPost[]> {
const posts: Record<string, IPost[]> = {}
list.forEach((item) => {
const labels = item.metadata.labels || {}
let postItemKey = ''
if (tab.value.activeIndex === 0) {
postItemKey = `${labels[postLabelYearKey]}-${labels[postLabelMonthKey]}`
}
else {
postItemKey = `${labels[postLabelYearKey]}`
}
if (posts[postItemKey]) {
posts[postItemKey].push(item)
}
else {
posts[postItemKey] = [item]
}
})
return posts
}
/** 处理成显示数据(分组 + 排序) */
function handleGetShowDataList(posts: Record<string, IPost[]>): typeof dataList.value {
const listResult: typeof dataList.value = []
Object.keys(posts).forEach((key) => {
const postData = {
sort: 0,
key,
year: key,
month: '',
posts: posts[key],
}
if (tab.value.activeIndex === 0) {
const splitDate = key.split('-')
postData.year = splitDate[0]
postData.month = splitDate[1]
postData.sort = Number(key.replace('-', ''))
}
else {
postData.sort = Number(key)
}
listResult.push(postData)
})
listResult.sort((a, b) => Number(b.sort) - Number(a.sort))
return listResult
}
/** 去重缓存列表 */
function handleUniqueCacheDatalist(list: IPost[]): IPost[] {
const seen = new Set<string>()
return list.filter((item) => {
return seen.has(item.metadata.name) ? false : (seen.add(item.metadata.name), true)
})
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
const archivesMock = mockJson.value.archives as { list?: { time?: string, cover?: string, title?: string, desc?: string }[] } | undefined
const dataListMock: IPost[] = (archivesMock?.list || []).map((item) => {
const date = new Date(item.time || Date.now())
const year = date.getFullYear()
const month = date.getMonth() + 1
return {
metadata: {
name: String(Date.now() * Math.random()),
labels: {
[postLabelYearKey]: String(year),
[postLabelMonthKey]: String(month),
},
},
spec: {
title: item.title || '',
slug: '',
cover: item.cover,
pinned: false,
publishTime: item.time,
deleted: false,
publish: true,
allowComment: true,
visible: 'PUBLIC',
priority: 0,
categories: [],
tags: [],
},
status: { permalink: '', inProgress: false, excerpt: item.desc },
stats: { visit: 0 },
}
})
const posts = handleGetPosts(dataListMock)
dataList.value = handleGetShowDataList(posts)
cacheDataList.value = dataListMock
loading.value = 'success'
loadMoreText.value = '呜呜,没有更多数据啦~'
uni.hideLoading()
uni.stopPullDownRefresh()
return
}
if (isLoadMore.value) {
uni.showLoading({ title: '加载中...' })
}
else {
loading.value = 'loading'
}
loadMoreText.value = '加载中...'
try {
const data = await getPostList({ ...queryParams.value })
result.value = { hasNext: data.hasNext }
const posts = handleGetPosts(data.items)
const showDataList = handleGetShowDataList(posts)
if (isLoadMore.value) {
cacheDataList.value = handleUniqueCacheDatalist([...cacheDataList.value, ...data.items])
// 合并增量数据
showDataList.forEach((item) => {
const find = dataList.value.find(x => x.key === item.key)
if (find) {
item.posts.forEach((post) => {
if (!find.posts.some(x => x.metadata.name === post.metadata.name)) {
find.posts.push(post)
}
})
}
})
showDataList.forEach((post) => {
if (!dataList.value.some(x => x.key === post.key)) {
dataList.value.push(post)
}
})
dataList.value.sort((a, b) => Number(b.sort) - Number(a.sort))
}
else {
dataList.value = showDataList
cacheDataList.value = data.items
}
loading.value = 'success'
loadMoreText.value = data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
function handleOnTabChange(index: number) {
tab.value.activeIndex = index
queryParams.value.page = 1
dataList.value = handleGetShowDataList(handleGetPosts(cacheDataList.value))
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
}
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 handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function formatTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 生命周期 ---------------- */
handleGetData()
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 1
handleGetData()
})
onReachBottom(() => {
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
return
}
if (result.value.hasNext) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<!-- 顶部 tab -->
<view class="archive-tabs fixed inset-x-0 top-0 z-6 bg-white">
<wd-tabs
v-model="tab.activeIndex"
:tabs="tab.list.map(title => ({ title }))"
align="center"
@change="handleOnTabChange"
/>
</view>
<view class="h-[90rpx] w-screen" />
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap p-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<!-- 内容区域 -->
<block v-else>
<view v-if="dataList.length === 0" class="list-empty h-screen w-screen flex items-center justify-center">
<wd-empty :description="calcAuditModeEnabled ? '暂无归档的内容' : '暂无归档的文章'" />
</view>
<view v-else class="timeline mt-6 px-6">
<view v-for="(item, index) in dataList" :key="item.key" class="timeline-item flex">
<view class="timeline-left w-[160rpx] flex shrink-0 flex-col items-center">
<view class="timeline-dot mt-1 h-6 w-6 rounded-full" style="background-color: #64b5f6; box-shadow: 0 4rpx 12rpx rgb(100 181 246 / 40%);" />
<view v-if="index !== dataList.length - 1" class="timeline-line mt-1 w-0.5 flex-1 bg-[#e0e0e0]" />
</view>
<view class="timeline-content flex-1 pb-12 pl-6">
<view class="time mb-6 flex items-center font-bold">
<text class="time-text text-[30rpx]">{{ item.year }}</text>
<text v-if="tab.activeIndex === 0" class="time-text text-[30rpx]">{{ item.month }}</text>
<text class="time-count ml-3 text-[22rpx] text-[#999] font-normal"> {{ item.posts.length }} {{ calcAuditModeEnabled ? '内容' : '文章' }}</text>
</view>
<view v-if="item.posts.length !== 0">
<view
v-for="post in item.posts"
:key="post.metadata.name"
class="post mb-6 flex rounded-xl bg-white p-6 shadow-sm"
:class="[globalAppSettings.layout.cardType]"
@click="handleToArticleDetail(post)"
>
<image class="post-thumbnail h-[170rpx] w-[200rpx] shrink-0 rounded-lg" :src="checkThumbnailUrl(post.spec.cover)" mode="aspectFill" lazy-load />
<view class="post-info w-0 flex-1 pl-5">
<view class="post-info-title text-overflow text-[28rpx] text-[#303133] font-bold">
{{ post.spec.title }}
</view>
<view class="post-info-summary line-clamp-2 mt-3 text-[24rpx] text-[#909399]">
{{ post.status?.excerpt }}
</view>
<view class="post-info-time mt-3 text-[24rpx] text-[#909399]">
发布时间{{ formatTime(post.spec.publishTime) }}
</view>
</view>
</view>
</view>
<view v-else class="post-empty py-6 text-[26rpx] text-[#909399]">
该日期下暂无归档文章
</view>
</view>
</view>
</view>
<view class="load-text pb-6 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.timeline {
.post {
&.tb_image_text,
&.tb_text_image {
flex-direction: column;
.post-thumbnail {
width: 100%;
height: 220rpx;
}
.post-info {
width: 100%;
padding-left: 0;
}
}
&.lr_text_image {
.post-thumbnail {
order: 2;
}
.post-info {
order: 1;
padding-left: 0;
padding-right: 24rpx;
}
}
&.only_text {
.post-thumbnail {
display: none;
}
.post-info {
padding: 6rpx;
}
}
}
}
</style>
@@ -0,0 +1,716 @@
<script lang="ts" setup>
/**
* 文章详情页(源自旧项目 pagesA/article-detail/article-detail.vue,新建复刻)
* 功能:文章头部(标题/作者/封面/统计) + 分类标签 + mp-html 内容渲染 + 受限阅读 + 点赞 + 评论
* TODO: 投票(article-vote)、豆瓣(article-douban)、分享海报(liu-poster)待阶段2/3 补充
*/
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
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 { 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'
definePage({
style: {
navigationBarTitleText: '内容详情',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const haloConfigs = computed(() => appConfigStore.configs)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryName = ref('')
const result = ref<IPost & {
_voteIds?: string[]
_doubanUrls?: string[]
owner?: { displayName?: string, avatar?: string }
stats?: { visit?: number, upvote?: number, comment?: number }
} | null>(null)
const showContentArr = ref<string[]>([])
const restrictReadInputCode = ref('')
const commentListScrollTop = ref(0)
const passwordModal = ref({ show: false })
const verificationCodeModal = ref({
show: false,
type: '',
imgUrl: '',
})
const commentModal = ref({
show: false,
isComment: false,
postName: '',
title: '',
})
const commentDetail = ref({
show: false,
loading: 'loading' as 'loading' | 'success' | 'error',
comment: {} as IComment,
postName: '',
list: [] as IComment[],
})
/* ---------------- 计算属性 ---------------- */
const postDetailConfig = computed(() => (haloConfigs.value.basicConfig as { postDetailConfig?: Record<string, unknown> } | undefined)?.postDetailConfig)
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.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[] {
const regex = /<vote-block\s+id="(vote-\w+)"\s*\/?>/g
const ids: string[] = []
for (const match of html.matchAll(regex)) {
ids.push(match[1])
}
return ids
}
/** 从 HTML 提取豆瓣块 url */
function extractDoubanBlockUrls(html: string): string[] {
const regex = /<douban\s+src="([^"]+)"\s*\/?>/g
const urls: string[] = []
for (const match of html.matchAll(regex)) {
urls.push(match[1])
}
return urls
}
/** 移除内容中的 tag 链接 */
function removeTagLinksCompletely(html: string): string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
return html.replace(regex, '')
}
/** 获取 openid(微信端) */
function handleGetOpenid() {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: (loginRes) => {
try {
uni.setStorageSync('openid', loginRes.code)
}
catch (error) {
console.error(error)
}
},
})
// #endif
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
try {
const res = await getPostByName(queryName.value)
const tempResult = res.data as typeof result.value
if (tempResult) {
tempResult._voteIds = extractVoteBlockIds(res.data.content?.raw || res.data.content?.content || '')
tempResult._doubanUrls = extractDoubanBlockUrls(res.data.content?.raw || res.data.content?.content || '')
const openid = uni.getStorageSync('openid')
if (openid === '' || openid === null) {
handleGetOpenid()
}
// 受限阅读:拆分可展示内容
if (checkPostRestrictRead(res.data)) {
showContentArr.value = getShowableContent(res.data)
}
else {
showContentArr.value = []
}
}
result.value = tempResult
uni.setNavigationBarTitle({ title: '文章详情' })
loading.value = 'success'
handleTrackersCounter()
}
catch (err) {
console.error('获取文章失败', err)
loading.value = 'error'
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
/** 访问计数埋点 */
async function handleTrackersCounter() {
if (!result.value)
return
const winInfo = uni.getWindowInfo()
const appBaseInfo = uni.getAppBaseInfo()
const baseUrl = import.meta.env.VITE_SERVER_BASEURL || ''
try {
await postTrackersCounter({
group: 'content.halo.run',
plural: 'posts',
name: result.value.metadata.name,
hostname: getDomainOnly(baseUrl),
screen: `${winInfo.screenWidth}x${winInfo.screenHeight}`,
language: appBaseInfo.language,
url: `/archives/${baseUrl}`,
referrer: `${baseUrl}/`,
})
}
catch (err) {
console.error('埋点失败', err)
}
}
/* ---------------- 点赞 ---------------- */
const upvotedNames = ref<string[]>([])
function hasUpvoted(): boolean {
return upvotedNames.value.includes(result.value?.metadata.name || '')
}
async function handleDoLikes() {
if (!result.value)
return
if (hasUpvoted()) {
uni.showToast({ icon: 'none', title: '已经点过赞啦!' })
return
}
try {
await submitUpvote({
group: 'content.halo.run',
plural: 'posts',
name: result.value.metadata.name,
})
uni.showToast({ icon: 'none', title: '点赞成功!' })
upvotedNames.value.push(result.value.metadata.name)
if (result.value.stats) {
result.value.stats.upvote = (result.value.stats.upvote || 0) + 1
}
}
catch (err) {
console.error('点赞失败', err)
uni.showToast({ icon: 'none', title: '点赞失败' })
}
}
/* ---------------- 受限阅读 ---------------- */
function readMore() {
const annotations = result.value?.metadata?.annotations
const restrictReadEnable = annotations?.restrictReadEnable
if (restrictReadEnable === 'password') {
passwordModal.value.show = true
}
else if (restrictReadEnable === 'code') {
verificationCodeModal.value.show = true
verificationCodeModal.value.type = 'scan'
verificationCodeModal.value.imgUrl = checkImageUrl((haloConfigs.value.pluginConfig?.toolsPlugin as { scanCodeUrl?: string } | undefined)?.scanCodeUrl)
}
else if (restrictReadEnable === 'comment') {
handleToComment()
}
else if (restrictReadEnable === 'login') {
uni.showToast({ title: '前往web端登录后访问', icon: 'none' })
}
else if (restrictReadEnable === 'pay') {
uni.showToast({ title: '前往web端支付后访问', icon: 'none' })
}
// 两秒后复制原文链接
setTimeout(() => {
if (result.value?.status?.permalink) {
copyToClipboard(import.meta.env.VITE_SERVER_BASEURL + result.value.status.permalink)
}
}, 2000)
}
/** 校验密码/验证码 */
async function restrictReadCheck() {
if (!result.value)
return
if (!restrictReadInputCode.value) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
const res = await requestRestrictReadCheck(
(result.value.metadata.annotations?.restrictReadEnable || 'password') as RestrictReadType,
restrictReadInputCode.value,
result.value.metadata.name,
)
if (res.code === 200) {
passwordModal.value.show = false
verificationCodeModal.value.show = false
handleGetData()
}
else {
uni.showToast({ title: '密码错误', icon: 'none' })
}
}
catch (err) {
console.error(err)
}
}
/** 获取验证码(受限阅读 code 模式) */
async function getVerificationCode() {
uni.showLoading({ title: '正在获取...' })
try {
const res = await createVerificationCode()
if (res.code === 200) {
verificationCodeModal.value.show = false
restrictReadInputCode.value = res.data as string || ''
restrictReadCheck()
}
else {
uni.showToast({ icon: 'none', title: '操作失败,请重试!' })
}
}
catch (err) {
uni.showToast({ icon: 'none', title: (err as Error).message || '操作失败' })
}
finally {
uni.hideLoading()
}
}
/* ---------------- 评论 ---------------- */
function handleToComment() {
if (!result.value)
return
if (!calcIsShowComment.value)
return
if (!result.value.spec.allowComment) {
uni.showToast({ icon: 'none', title: '文章已开启禁止评论!' })
return
}
commentModal.value = {
show: true,
isComment: true,
postName: result.value.metadata.name,
title: '新增评论',
}
}
function handleOnComment(data: { isComment: boolean, postName: string, title: string }) {
commentModal.value = {
show: true,
isComment: data.isComment,
postName: data.postName,
title: data.title,
}
}
function handleOnCommentModalClose(data: { refresh: boolean, isSubmit: boolean }) {
if (result.value?.metadata.annotations?.restrictReadEnable === 'comment') {
handleGetData()
}
if (data.refresh && data.isSubmit) {
// 评论成功后刷新(通过 uni.$emit 广播给 comment-list)
uni.$emit('comment_list_refresh')
}
commentModal.value.show = false
}
function handleOnShowCommentDetail(data: { postName: string, comment: IComment }) {
commentDetail.value = {
show: true,
loading: 'loading',
comment: data.comment,
postName: data.postName,
list: [],
}
}
async function handleGetChildComments() {
commentDetail.value.loading = 'loading'
try {
const res = await getPostCommentReplyList(commentDetail.value.postName, {
page: 1,
size: 100,
})
commentDetail.value.loading = 'success'
commentDetail.value.list = res.data.items
}
catch (err) {
console.error(err)
commentDetail.value.loading = 'error'
}
}
/* ---------------- 跳转 ---------------- */
function handleToCate(category: { metadata: { name: string }, spec: { displayName: string } }) {
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
function handleToTag(tag: { metadata: { name: string }, spec: { displayName: string } }) {
uni.navigateTo({
url: `/pages-blog/tag-detail/tag-detail?name=${tag.metadata.name}&title=${tag.spec.displayName}`,
})
}
function handleToWebview(data: { title: string, url: string }) {
uni.navigateTo({
url: `/pages-blog/website/website?data=${JSON.stringify({
title: data.title,
url: encodeURIComponent(data.url),
})}`,
})
}
function handleToOriginal(originalURLValue: string) {
handleToWebview({
title: result.value?.spec.title || '',
url: originalURLValue,
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function handlePreview(index: number, list: { url: string }[]) {
uni.previewImage({
current: index,
urls: list.map(item => item.url),
})
}
/* ---------------- 格式化 ---------------- */
function formatPublishTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
uni.setNavigationBarTitle({ title: '文章加载中...' })
queryName.value = options?.name || ''
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
onShareAppMessage(() => {
const cover = result.value?.spec.cover ? calcUrl(result.value.spec.cover) : ''
return {
path: `/pages-blog/article-detail/article-detail?name=${result.value?.metadata.name}`,
title: result.value?.spec.title || '',
imageUrl: cover,
}
})
onShareTimeline(() => {
const cover = result.value?.spec.cover ? calcUrl(result.value.spec.cover) : ''
return {
title: result.value?.spec.title || '',
query: result.value ? `name=${result.value.metadata.name}` : '',
imageUrl: cover,
}
})
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-[120rpx]" style="background-color: #fafafd;">
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap bg-white p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<!-- 顶部信息 -->
<view class="head mx-6 mt-6 flex flex-col items-center rounded-xl bg-white px-6 py-9 shadow-sm">
<view class="title text-center text-[36rpx] 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 mx-6 mt-6 rounded-xl bg-white p-6 text-[28rpx] shadow-sm">
<view class="category-type leading-[55rpx]">
<text class="category-label font-bold">分类</text>
<text v-if="!result?.categories?.length" class="category-tag is-empty rounded-md bg-[#607d8b] px-1.5 py-0.5 text-[24rpx] text-white">未选择分类</text>
<text v-for="(item, index) in result?.categories" v-else :key="index" class="category-tag mr-3 rounded-md bg-[#5bb8fa] px-1.5 py-0.5 text-[24rpx] text-white" @click="handleToCate(item)">
{{ item.spec.displayName }}
</text>
</view>
<view class="category-type leading-[55rpx]">
<text class="category-label font-bold">标签</text>
<text v-if="!result?.tags?.length" class="category-tag is-empty rounded-md bg-[#607d8b] px-1.5 py-0.5 text-[24rpx] text-white">未选择标签</text>
<text
v-for="(item, index) in result?.tags"
v-else
:key="index"
class="category-tag mr-3 rounded-md px-1.5 py-0.5 text-[24rpx] text-white"
:style="{ backgroundColor: item.spec.color || '#5bb8fa' }"
@click="handleToTag(item)"
>
{{ item.spec.displayName }}
</text>
</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)">{{ originalURL }}</text>
<text class="original-url-btn flex-1 text-right text-[#03a9f4]" @click.stop="handleToOriginal(originalURL)">阅读原文</text>
</view>
</view>
</view>
<!-- 内容区域 -->
<view class="content mx-6 mt-6">
<view class="markdown-wrap overflow-hidden rounded-xl bg-white p-1.5 shadow-sm">
<!-- 受限阅读 -->
<template v-if="checkPostRestrictRead(result!)">
<view v-if="showContentArr.length === 0">
<uh-restrict-read-skeleton
:loading="true"
:lines="3"
:tip-text="`此处内容已隐藏,「${getRestrictReadTypeName(result!)}可见」`"
:button-text="getRestrictReadTypeName(result!)"
button-color="#1890ff"
@refresh="readMore"
/>
</view>
<view v-for="(showContent, showContentIndex) in showContentArr" v-else :key="showContentIndex">
<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"
:tip-text="`此处内容已隐藏,「${getRestrictReadTypeName(result!)}可见」`"
:button-text="getRestrictReadTypeName(result!)"
button-color="#1890ff"
@refresh="readMore"
/>
</view>
</template>
<!-- 正常渲染 -->
<template v-else>
<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
/>
</template>
</view>
<!-- 版权声明 -->
<view v-if="postDetailConfig?.copyrightEnabled" class="card-wrap mt-6 rounded-xl bg-white p-6 shadow-sm">
<view class="card-title relative box-border pl-6 text-[30rpx] font-bold">
<text class="absolute left-0 top-2 h-[26rpx] w-2 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]">
版权归属{{ postDetailConfig.copyrightAuthor }}
</view>
<view v-if="postDetailConfig.copyrightDesc" class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]">
版权说明{{ postDetailConfig.copyrightDesc }}
</view>
<view v-if="postDetailConfig.copyrightViolation" class="copyright-text text-[26rpx] text-[#f56c6c] leading-[1.7]">
侵权处理{{ postDetailConfig.copyrightViolation }}
</view>
</view>
</view>
<!-- 评论区域 -->
<view v-if="calcIsShowComment && result" class="card-wrap mt-6 rounded-xl bg-white p-6 shadow-sm">
<uh-comment-list
: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-8 z-999 flex flex-col gap-1.5">
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<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="hasUpvoted() ? 'heart' : 'heart-outline'" 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="chat" size="20px" color="#4caf50" />
</view>
</view>
</block>
<!-- 密码弹窗 -->
<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"
>
<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" />
<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"
/>
</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>
+211
View File
@@ -0,0 +1,211 @@
<script lang="ts" setup>
/**
* 内容搜索页(源自旧项目 pagesA/articles,新建复刻)
* 功能:关键词搜索文章/瞬间,结果列表展示
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getPostListByKeyword } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import { markdownConfig } from '@/config/markdown'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
definePage({
style: {
navigationBarTitleText: '内容搜索',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const calcAuditModeEnabled = computed(() => !!appConfigStore.configs.auditConfig?.auditModeEnabled)
/** 依赖插件(plugin-search-widget) */
const uniHaloPluginId = 'plugin-search-widget'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({
keyword: '',
limit: 50,
highlightPreTag: '',
highlightPostTag: '',
})
const dataList = ref<{
metadataName?: string
type?: string
title?: string
description?: string
content?: string
updateTimestamp?: string
}[]>([])
/* ---------------- 搜索 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value)
return
loading.value = 'loading'
try {
const res = await getPostListByKeyword({ ...queryParams.value })
loading.value = 'success'
dataList.value = (res.data as unknown as { hits?: typeof dataList.value }).hits || []
}
catch (err) {
console.error(err)
loading.value = 'error'
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 800)
}
}
function handleOnSearch() {
if (!queryParams.value.keyword) {
dataList.value = []
loading.value = 'success'
}
else {
handleGetData()
}
}
function isArticle(item: { type?: string }): boolean {
return item.type === 'post.content.halo.run'
}
function handleToDetail(item: { metadataName?: string, type?: string }) {
if (calcAuditModeEnabled.value)
return
if (isArticle(item)) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${item.metadataName}`,
animationType: 'slide-in-right',
})
}
else {
uni.navigateTo({
url: `/pages-blog/moment-detail/moment-detail?name=${item.metadataName}`,
animationType: 'slide-in-right',
})
}
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
if (!queryParams.value.keyword) {
loading.value = 'success'
}
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleOnSearch()
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafd;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用搜索功能哦请联系管理员"
@on-refresh="handleOnSearch"
/>
<template v-else>
<!-- 顶部搜索框 -->
<view class="search-bar fixed inset-x-0 top-0 z-6 bg-white px-3 py-2 shadow-sm">
<view class="search-input h-[68rpx] flex items-center gap-3 rounded-[34rpx] bg-[#f5f5f5] px-6">
<wd-icon name="search" size="16px" color="#999" />
<input
v-model="queryParams.keyword"
class="search-field flex-1 text-[26rpx]"
placeholder="搜索内容..."
confirm-type="search"
@confirm="handleOnSearch"
>
<view v-if="queryParams.keyword" class="clear-btn flex items-center" @click="queryParams.keyword = ''; handleOnSearch()">
<wd-icon name="close" size="14px" color="#999" />
</view>
</view>
</view>
<view class="h-[100rpx] w-screen" />
<!-- 骨架屏 -->
<view v-if="loading === 'loading'" class="loading-wrap p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<view v-else-if="loading === 'error'" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="搜索异常" />
</view>
<!-- 内容区域 -->
<view v-else class="content pt-6">
<view v-if="dataList.length === 0" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty v-if="!queryParams.keyword" description="请输入关键词搜索" />
<wd-empty v-else :description="`未搜到 ${queryParams.keyword} 相关内容`" />
</view>
<block v-else>
<view v-for="(item, index) in dataList" :key="index" class="article-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm" @click="handleToDetail(item)">
<view class="card-head mb-3 flex items-center">
<view class="type-tag mr-3 shrink-0 rounded-md px-1.5 py-0.5 text-[22rpx] text-white" :class="isArticle(item) ? 'bg-[#2196f3]' : 'bg-[#4caf50]'">
{{ isArticle(item) ? '文章' : '瞬间' }}
</view>
<text class="card-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[28rpx] text-[#333] font-bold">{{ item.title }}</text>
</view>
<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="item.description || item.content || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
<view class="card-foot mt-3 flex items-center">
<text class="text-[24rpx] text-[#888]">{{ item.updateTimestamp ? `最近更新:${formatTimeUtil({ d: item.updateTimestamp, f: 'yyyy年MM月dd日 HH点mm分ss秒' })}` : '' }}</text>
</view>
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
@@ -0,0 +1,142 @@
<script lang="ts" setup>
/**
* 分类详情页(源自旧项目 pagesA/category-detail,新建复刻)
* 展示某分类下的文章列表,分页加载
*/
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getCategoryPostList } from '@/api/halo'
import type { IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '分类详情',
enablePullDownRefresh: true,
},
})
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 0 })
const name = ref('')
const pageTitle = ref('加载中...')
const hasNext = ref(false)
const dataList = ref<IPost[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref('')
async function handleGetData() {
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = '加载中...'
try {
const res = await getCategoryPostList(name.value, { ...queryParams.value })
uni.setNavigationBarTitle({ title: `${pageTitle.value} (共${res.data.total}篇)` })
hasNext.value = res.data.hasNext
dataList.value = isLoadMore.value
? dataList.value.concat(res.data.items)
: res.data.items
loadMoreText.value = res.data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
setTimeout(() => {
loading.value = 'success'
}, 500)
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 500)
}
}
function handleToArticleDetail(article: IPost) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
onLoad((options) => {
name.value = options?.name || ''
pageTitle.value = options?.title || '分类详情'
handleGetData()
})
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 0
handleGetData()
})
onReachBottom(() => {
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
onShareAppMessage(() => ({
title: pageTitle.value,
path: `/pages-blog/category-detail/category-detail?name=${name.value}&title=${pageTitle.value}`,
}))
onShareTimeline(() => ({
title: pageTitle.value,
path: `/pages-blog/category-detail/category-detail?name=${name.value}&title=${pageTitle.value}`,
}))
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col py-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<view v-if="dataList.length === 0" class="empty h-[60vh] flex items-center justify-center">
<wd-empty description="该分类下暂无文章" />
</view>
<block v-else>
<uh-article-card
v-for="(article, index) in dataList"
:key="index"
:article="article"
@on-click="handleToArticleDetail"
/>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts" setup>
/**
* 联系博主页(源自旧项目 pagesA/contact,新建复刻)
* 展示博主社交联系方式,点击复制
*/
import { computed, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '联系博主',
},
})
const appConfigStore = useAppConfigStore()
const authorConfig = computed(() => appConfigStore.configs.authorConfig)
const bloggerInfo = computed(() => {
const blogger = authorConfig.value?.blogger as { nickname?: string, avatar?: string, description?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
description: blogger?.description || '',
}
})
const socialConfig = computed(() => (authorConfig.value?.social as Record<string, unknown> | undefined) || {})
/** 联系方式列表(从配置填充) */
const result = ref<{ key: string, name: string, value: string }[]>([
{ key: 'qq', name: '企鹅号', value: '' },
{ key: 'wechat', name: '微信号', value: '' },
{ key: 'github', name: 'Github', value: '' },
{ key: 'gitee', name: 'Gitee', value: '' },
{ key: 'bilibili', name: 'Bilibili', value: '' },
{ key: 'csdn', name: 'CSDN', value: '' },
{ key: 'blog', name: '博客地址', value: '' },
{ key: 'juejin', name: '掘金地址', value: '' },
{ key: 'weibo', name: '微博地址', value: '' },
{ key: 'email', name: '邮箱地址', value: '' },
])
const calcIsNotEmpty = computed(() => result.value.some(item => item.value !== ''))
function handleGetData() {
for (const key in socialConfig.value) {
if (key === 'enabled')
continue
const item = result.value.find(x => x.key === key)
if (item) {
item.value = String(socialConfig.value[key] || '')
}
}
}
function handleOnClick(item: { value: string, name: string }) {
if (!item.value)
return
uni.setClipboardData({
data: item.value,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: `${item.name} 已复制!` })
},
})
}
watch(socialConfig, () => {
handleGetData()
}, { deep: true, immediate: true })
onLoad(() => {
uni.setNavigationBarTitle({ title: '联系博主' })
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col items-center bg-white pt-[160rpx]">
<!-- 博主信息 -->
<view class="profile flex flex-col items-center p-9">
<view class="avatar relative box-border h-[170rpx] w-[170rpx] overflow-hidden border-6 border-white rounded-full shadow-sm">
<image class="avatar-img h-full w-full" :src="bloggerInfo.avatar" mode="aspectFill" />
</view>
<view class="nickname mt-6 text-[38rpx] font-bold">
{{ bloggerInfo.nickname }}
</view>
<view class="desc mt-6 text-center text-[26rpx] text-[#666]">
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
</view>
</view>
<!-- 联系方式列表 -->
<view class="contact box-border w-full px-12 pt-12" style="border-top: 2rpx solid #f2f2f2;">
<block v-if="calcIsNotEmpty">
<view
v-for="item in result.filter(i => i.value)"
:key="item.key"
class="item mt-6 box-border rounded-xl bg-[#fafafa] p-4"
@click="handleOnClick(item)"
>
<view class="left box-border w-[160rpx] flex items-center">
<text class="name text-[24rpx] text-[#555]">{{ item.name }}</text>
</view>
<view class="right box-border w-0 flex flex-1 flex-wrap items-center break-all pl-3 text-[24rpx] text-[#333]">
{{ item.value }}
</view>
</view>
</block>
<view v-else class="empty pt-12">
<wd-empty description="暂无联系方式" />
</view>
</view>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script lang="ts" setup>
/**
* 数据看板页(源自旧项目 pagesA/data-visual,新建复刻)
* 标签统计/分类统计(环形图)、文章发布趋势(热度图)、评论活跃用户(柱状图)、热门文章 Top10(柱状图)
*/
import { ref } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { getChartData } from '@/api/uni-halo'
import { usePluginAvailable } from '@/utils/plugin'
import type { IDataStatistics } from '@/api/uni-halo'
definePage({
style: {
navigationBarTitleText: '数据看板',
enablePullDownRefresh: true,
},
})
/** 依赖插件(plugin-data-statistics) */
const uniHaloPluginId = 'plugin-data-statistics'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const statistics = ref<IDataStatistics>({
tags: [],
categories: [],
articles: [],
comments: [],
top10Articles: [],
})
/* ---------------- 图表配置 ---------------- */
const chartColors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#ea7ccc', '#0EA5E9']
/** 标签统计(环形图) */
const tagChart = ref({
isExpand: true,
type: 'ring',
data: { series: [{ data: [] as { name: string, value: number }[] }] },
})
/** 分类统计(柱状图) */
const categoryChart = ref({
isExpand: true,
type: 'column',
data: { categories: [] as string[], series: [{ name: '分类', data: [] as number[] }] },
})
/** 文章发布趋势(热度图) */
const trandArticleChart = ref({
isExpand: true,
type: 'hotmap',
data: [] as { date: string, count: number }[],
})
/** 评论活跃用户(柱状图) */
const userCommentsChart = ref({
isExpand: true,
type: 'column',
data: { categories: [] as string[], series: [{ name: '评论', data: [] as number[] }] },
})
/** 热门文章 Top10(柱状图) */
const top10ArticlesChart = ref({
isExpand: true,
type: 'column',
data: { categories: [] as string[], series: [{ name: '访问量', data: [] as number[] }] },
})
/* ---------------- 数据处理 ---------------- */
function handleTagChart() {
const data = [...statistics.value.tags].sort((a, b) => b.count - a.count)
tagChart.value.data = {
series: [
{
data: data.map(item => ({ name: item.name, value: item.count })),
},
],
}
}
function handleCategoriesChart() {
const data = [...statistics.value.categories].sort((a, b) => b.total - a.total)
categoryChart.value.data = {
categories: data.map(item => item.name),
series: [{ name: '分类', data: data.map(item => item.total) }],
}
}
function handleTrendArticlesChart() {
trandArticleChart.value.data = statistics.value.articles.map(item => ({
date: item.date,
count: item.count,
}))
}
function handleUserCommentsChart() {
const data = [...statistics.value.comments].sort((a, b) => b.count - a.count).slice(0, 10)
userCommentsChart.value.data = {
categories: data.map(item => item.username),
series: [{ name: '评论', data: data.map(item => item.count) }],
}
}
function handleTop10ArticlesChart() {
const data = [...statistics.value.top10Articles].sort((a, b) => b.views - a.views).slice(0, 10)
top10ArticlesChart.value.data = {
categories: data.map(item => item.name),
series: [{ name: '访问量', data: data.map(item => item.views) }],
}
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
uni.showLoading({ mask: true, title: '加载中...' })
loading.value = 'loading'
try {
const res = await getChartData()
statistics.value = res.data
handleTagChart()
handleCategoriesChart()
handleTrendArticlesChart()
handleUserCommentsChart()
handleTop10ArticlesChart()
loading.value = 'success'
}
catch (err) {
console.error(err)
loading.value = 'error'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 100)
}
}
/* ---------------- 生命周期 ---------------- */
async function init() {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
}
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
init()
</script>
<template>
<view class="app-page box-border min-h-screen w-screen p-6 text-[#353437]" style="background-color: #fafafd;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="阿偶检测到当前插件没有安装或者启用无法使用功能哦请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else>
<!-- 加载/错误 -->
<view v-if="loading === 'loading'" class="loading-wrap p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<view v-else-if="loading === 'error'" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="加载异常" />
</view>
<!-- 内容区域 -->
<view v-else class="content flex flex-col gap-6">
<!-- 标签统计 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head flex items-center justify-between" @click="tagChart.isExpand = !tagChart.isExpand">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">标签统计</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">全部标签的文章数量占比</text>
</view>
<wd-icon :name="tagChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
</view>
<view v-show="tagChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<qiun-data-charts
type="ring"
:chart-data="tagChart.data"
:opts="{ color: chartColors, padding: [5, 5, 5, 5], dataLabel: false, legend: { show: false }, extra: { ring: { ringWidth: 36, offsetAngle: -90, border: true, borderWidth: 1, borderColor: '#FFFFFF' } } }"
/>
</view>
</view>
<!-- 分类统计 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head flex items-center justify-between" @click="categoryChart.isExpand = !categoryChart.isExpand">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">分类统计</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">全部分类的文章数量占比</text>
</view>
<wd-icon :name="categoryChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
</view>
<view v-show="categoryChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<qiun-data-charts
type="column"
:chart-data="categoryChart.data"
:opts="{ color: chartColors, padding: [20, 15, 10, 15], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 6 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view>
</view>
<!-- 文章发布趋势 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head flex items-center justify-between" @click="trandArticleChart.isExpand = !trandArticleChart.isExpand">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">文章发布趋势</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按日期统计文章发布数量</text>
</view>
<wd-icon :name="trandArticleChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
</view>
<view v-show="trandArticleChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<uh-heatmap :chart-data="trandArticleChart.data" />
</view>
</view>
<!-- 评论活跃用户 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head flex items-center justify-between" @click="userCommentsChart.isExpand = !userCommentsChart.isExpand">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">评论活跃用户</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按评论作者统计评论数量</text>
</view>
<wd-icon :name="userCommentsChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
</view>
<view v-show="userCommentsChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<qiun-data-charts
type="column"
:chart-data="userCommentsChart.data"
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view>
</view>
<!-- 热门文章 Top10 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">热门文章前10</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按访问量排序的热门文章</text>
</view>
</view>
<view class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<qiun-data-charts
type="column"
:chart-data="top10ArticlesChart.data"
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view>
</view>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.card-head-text {
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 70%;
background-color: #03a9f4;
border-radius: 12rpx;
}
}
</style>
@@ -0,0 +1,82 @@
<script lang="ts" setup>
/**
* 免责声明页(源自旧项目 pagesA/disclaimers,新建复刻)
*/
import { computed } from 'vue'
import { useAppConfigStore } from '@/store/appConfig'
definePage({
style: {
navigationBarTitleText: '免责声明',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const disclaimersContent = computed(() => {
const basicConfig = haloConfigs.value.basicConfig as { disclaimers?: { content?: string } } | undefined
return basicConfig?.disclaimers?.content || ''
})
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, email?: string } | undefined
return {
nickname: blogger?.nickname || '',
email: blogger?.email || '',
}
})
function copyText(content: string, tips = '复制成功') {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
</script>
<template>
<view class="app-page box-border min-h-screen bg-white p-12 text-[30rpx] text-[#303133] leading-[1.65]">
<!-- 通过配置 -->
<view v-if="disclaimersContent" style="min-height: 100%;" v-html="disclaimersContent" />
<!-- 静态写法 -->
<block v-else>
<view class="title text-center text-[34rpx] font-bold">
本博客免责声明
</view>
<view class="item mt-6">
1本博客属于个人非盈利性质的网站所有转载的文章都以遵循原作者的版权声明注明了文章来源
</view>
<view class="item mt-6">
2如果原文没有版权声明按照目前互联网开放的原则本博客将在不通知作者的情况下转载文章
</view>
<view class="item mt-6">
3如果原文明确注明"禁止转载"本博客将不会转载
</view>
<view class="item mt-6">
4如果本博客转载的文章不符合作者的版权声明或者作者不想让本博客转载您的文章请邮件告知
<text class="email mx-3 text-[#03a9f4]" @click="copyText(bloggerInfo.email, '电子邮箱已复制到剪贴板!')">{{ bloggerInfo.email }}</text>
博主将会在第一时间删除相关信息
</view>
<view class="item mt-6">
5本博客转载文章仅为留做备份和知识点分享的目的
</view>
<view class="item mt-6">
6本博客将尽力确保所提供信息的准确性及可靠性但不保证信息的正确性和完整性且不对因信息的不正确或遗漏导致的任何损失或损害承担相关责任
</view>
<view class="item mt-6">
7本博客所发布转载的文章其版权均归原作者所有如其他自媒体网站或个人从本博客下载使用请在转载有关文章时务必尊重该文章的著作权保留本博客注明的"原文来源"或者自行去原文处复制版权声明并自负版权等法律责任
</view>
<view class="item mt-6">
8本博客的所有原创文章皆可以任意转载但转载时务必请注明出处
</view>
<view class="item mt-6">
9尊重原创知识共享
</view>
</block>
</view>
</template>
@@ -0,0 +1,274 @@
<script lang="ts" setup>
/**
* 友情链接页(源自旧项目 pagesA/friend-links,新建复刻)
* 展示友链列表(色彩版/简洁版),支持分组名解析、详情弹窗、申请入口
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getFriendLinkGroupList, getFriendLinkList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { usePluginAvailable } from '@/utils/plugin'
import type { ILink, ILinkGroup } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '友情链接',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const haloPluginConfigs = computed(() => appConfigStore.configs.pluginConfig)
const globalAppSettings = computed(() => settingStore.settings)
/** 依赖插件(plugin-links) */
const uniHaloPluginId = 'plugin-links'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 1 })
const detail = ref<{ show: boolean, data: ILink | null }>({ show: false, data: null })
const hasNext = ref(false)
const isLoadMore = ref(false)
const loadMoreText = ref('')
const linkGroupList = ref<ILinkGroup[]>([])
const dataList = ref<ILink[]>([])
/* ---------------- 数据加载 ---------------- */
function findLinkGroupDisplayNameByGroupMetadataName(groupName?: string): string {
if (linkGroupList.value.length === 0)
return groupName || '未分组'
const found = linkGroupList.value.find(item => item.metadata.name === groupName)
return found?.spec.displayName || groupName || '未分组'
}
async function handleGetLinkGroupData() {
try {
const res = await getFriendLinkGroupList({ page: 1, size: 0 })
linkGroupList.value = res.data.items || []
handleGetData()
}
catch (err) {
console.error(err)
loading.value = 'error'
}
}
async function handleGetData() {
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = ''
try {
const res = await getFriendLinkList({ ...queryParams.value })
hasNext.value = res.data.hasNext
const list = res.data.items.map(item => ({
...item,
spec: {
...item.spec,
logo: checkAvatarUrl(item.spec.logo),
groupName: findLinkGroupDisplayNameByGroupMetadataName(item.spec.groupName),
},
}))
dataList.value = dataList.value.concat(list)
setTimeout(() => {
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}, 500)
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 500)
}
}
/* ---------------- 交互 ---------------- */
function handleOnLinkEvent(link: ILink) {
detail.value = { show: true, data: link }
}
function handleCopyLink(link: ILink) {
uni.setClipboardData({
data: `${link.spec.displayName}:${link.spec.url}`,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: '链接复制成功!' })
},
fail: () => {
uni.showToast({ icon: 'none', title: '复制失败!' })
},
})
}
function toSubmitLinkPage() {
uni.navigateTo({ url: '/pages-blog/submit-link/submit-link' })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function calcSiteThumbnail(val?: string): string {
if (!val)
return ''
const _val = val.endsWith('/') ? val : `${val}/`
return `https://image.thum.io/get/width/1000/crop/800/${_val}`
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetLinkGroupData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
dataList.value = []
handleGetData()
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用友情链接功能哦请联系管理员"
@on-refresh="handleGetLinkGroupData"
/>
<template v-else>
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen p-3">
<wd-skeleton :row="5" :animated="true" />
</view>
<view v-else class="content pt-6" :class="{ 'bg-white': dataList.length !== 0 }">
<view v-if="dataList.length === 0" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="啊偶,博主还没有朋友呢~" />
</view>
<!-- 友链列表 -->
<view v-else class="link-list px-6">
<view v-for="(link, index) in dataList" :key="index">
<!-- 色彩版 -->
<view
v-if="!globalAppSettings.links.useSimple"
class="info flex bg-white p-3"
:class="{ 'border-b-2 border-[#f5f5f5]': index !== dataList.length - 1 }"
@click="handleOnLinkEvent(link)"
>
<image class="link-logo h-[140rpx] w-[140rpx] shrink-0 rounded-xl" :src="link.spec.logo" mode="aspectFill" />
<view class="info-detail flex flex-1 flex-col justify-center pl-7">
<view class="link-card-name text-[30rpx] text-[#f44336] font-bold">
<text class="group-tag mr-3 rounded-md px-1.5 py-0.5 text-[20rpx] text-white font-normal" style="background: linear-gradient(135deg, #64b5f6, #2196f3);">{{ link.spec.groupName || '暂未分组' }}</text>
{{ link.spec.displayName }}
</view>
<view class="link-card-url mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#666]">
站点地址{{ link.spec.url }}
</view>
<view class="link-card-desc mt-2 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#303133]">
博客简介{{ link.spec.description || '这个博主很懒,没写简介~' }}
</view>
</view>
</view>
<!-- 简洁版 -->
<view v-else class="link-card mb-6 flex items-center rounded-xl bg-white p-6 shadow-sm" @click="handleOnLinkEvent(link)">
<image class="logo h-[80rpx] w-[80rpx] shrink-0 border-6 border-white rounded-xl" :src="link.spec.logo" mode="aspectFill" />
<view class="link-info flex-1 pl-6">
<view class="name text-[30rpx] text-[#303133] font-bold">
{{ link.spec.displayName }}
</view>
<view class="desc mt-3 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#909399]">
{{ link.spec.description }}
</view>
</view>
</view>
</view>
</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="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view v-if="(haloPluginConfigs?.linksSubmitPlugin as { enabled?: boolean } | undefined)?.enabled" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="toSubmitLinkPage">
<wd-icon name="edit" size="20px" color="#ff9800" />
</view>
</view>
<!-- 详情弹窗 -->
<wd-popup v-model="detail.show" position="center" custom-style="width:640rpx;border-radius:12rpx;">
<view v-if="detail.data" class="poup p-9">
<view class="info flex">
<image class="poup-logo h-[140rpx] w-[140rpx] shrink-0 rounded-full" :src="checkImageUrl(detail.data.spec.logo)" mode="aspectFill" />
<view class="poup-info ml-6 flex flex-1 flex-col justify-center">
<view class="poup-name text-[34rpx] font-bold">
{{ detail.data.spec.displayName }}
</view>
<view class="poup-tag mt-2 text-[24rpx] text-[#999]">
{{ detail.data.spec.groupName }}
</view>
<view class="poup-link mt-3" @click="handleCopyLink(detail.data)">
<text class="poup-url text-[24rpx] text-[#ff9800]">{{ detail.data.spec.url }}</text>
</view>
</view>
</view>
<view class="poup-desc mt-5 text-[28rpx] text-[#555] leading-[1.6]">
博客简介{{ detail.data.spec.description || '这个博主很懒,没写简介~' }}
</view>
<image class="poup-img mt-6 h-[320rpx] w-[568rpx] rounded-xl" :src="calcSiteThumbnail(detail.data.spec.url)" mode="aspectFill" />
</view>
</wd-popup>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</view>
</template>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+273
View File
@@ -0,0 +1,273 @@
<script lang="ts" setup>
/**
* 恋爱相册页(源自旧项目 pagesA/love/album.vue,新建复刻)
* 相册列表(两列网格)+ 加密相册密码解锁 + 图片查看弹窗
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getLoveAlbumByName, getLoveAlbums } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { getCache, setCache } from '@/utils/storage'
import type { ILoveAlbum } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '恋爱相册',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const loveConfig = computed(() => appConfigStore.configs.loveConfig)
/** 已解锁相册本地缓存 key */
const UNLOCKED_ALBUMS_CACHE_KEY = 'unlocked_albums'
/** 解锁 token 有效期(后端默认 30 分钟) */
const ALBUM_TOKEN_TTL_SECONDS = 30 * 60
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const dataList = ref<(ILoveAlbum & { image?: string, takeTime?: string })[]>([])
const unlockedAlbums = ref<Record<string, string>>({})
/** 密码解锁弹窗 */
const showUnlockModal = ref(false)
const currentUnlockAlbum = ref<(ILoveAlbum & { image?: string }) | null>(null)
/** 图片查看弹窗 */
const showPhotoViewer = ref(false)
const currentViewerAlbum = ref<(ILoveAlbum & { image?: string }) | null>(null)
const viewerLoading = ref(false)
const unlockAlbumName = computed(() => currentUnlockAlbum.value?.displayName || '')
const unlockAlbumKey = computed(() => currentUnlockAlbum.value?.name || '')
const viewerAlbumName = computed(() => currentViewerAlbum.value?.displayName || '')
const viewerPhotos = computed(() => currentViewerAlbum.value?.photos || [])
/* ---------------- 缓存 ---------------- */
function handleRestoreUnlockedAlbums() {
try {
const saved = getCache<Record<string, string>>(UNLOCKED_ALBUMS_CACHE_KEY)
if (saved) {
unlockedAlbums.value = saved
}
}
catch (e) {
console.error('恢复解锁状态失败', e)
}
}
function handleSaveUnlockedAlbums() {
try {
setCache(UNLOCKED_ALBUMS_CACHE_KEY, unlockedAlbums.value, ALBUM_TOKEN_TTL_SECONDS)
}
catch (e) {
console.error('保存解锁状态失败', e)
}
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
try {
const res = await getLoveAlbums({})
if (res.data && (res.data as unknown as { items?: unknown[] }).items) {
dataList.value = ((res.data as unknown as { items: ILoveAlbum[] }).items || []).map((item) => {
const creationTimestamp = (item.metadata as unknown as { creationTimestamp?: string } | undefined)?.creationTimestamp
return {
...item,
image: checkImageUrl(item.cover),
takeTime: creationTimestamp ? dayjs(creationTimestamp).format('DD/MM/YYYY') : '',
}
})
loading.value = 'success'
handleLoadUnlockedAlbumPhotos()
}
else {
dataList.value = []
loading.value = 'success'
}
}
catch (e) {
console.error('获取相册失败', e)
loading.value = 'error'
uni.showToast({ icon: 'none', title: '加载失败,请下拉刷新重试!' })
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
/** 加载已解锁相册的照片 */
async function handleLoadUnlockedAlbumPhotos() {
for (const item of dataList.value) {
if (item.locked && unlockedAlbums.value[item.name || '']) {
const token = unlockedAlbums.value[item.name || '']
try {
const detail = await getLoveAlbumByName(item.name || '', { token })
if (detail.locked) {
delete unlockedAlbums.value[item.name || '']
handleSaveUnlockedAlbums()
}
else if (detail.photos) {
item.photos = detail.photos
item.locked = false
}
}
catch (e) {
console.error('加载相册照片失败', e)
}
}
}
}
/* ---------------- 交互 ---------------- */
function handleOnAlbumClick(item: ILoveAlbum & { image?: string }) {
if (item.locked && !unlockedAlbums.value[item.name || '']) {
currentUnlockAlbum.value = item
showUnlockModal.value = true
return
}
handleOpenPhotoViewer(item)
}
async function handleOpenPhotoViewer(item: ILoveAlbum & { image?: string }) {
currentViewerAlbum.value = item
showPhotoViewer.value = true
if (item.photos && item.photos.length > 0)
return
viewerLoading.value = true
try {
const token = unlockedAlbums.value[item.name || ''] || ''
const detail = await getLoveAlbumByName(item.name || '', { token })
if (detail) {
if (detail.locked) {
delete unlockedAlbums.value[item.name || '']
handleSaveUnlockedAlbums()
}
else if (detail.photos) {
item.photos = detail.photos
item.locked = false
}
}
}
catch (e) {
console.error('获取相册照片失败', e)
uni.showToast({ icon: 'none', title: '照片加载失败,请稍后重试' })
}
finally {
viewerLoading.value = false
}
}
function handleOnUnlockSuccess(data: { albumKey: string, token: string, photos: unknown[] }) {
unlockedAlbums.value[data.albumKey] = data.token
handleSaveUnlockedAlbums()
const albumIndex = dataList.value.findIndex(a => a.name === data.albumKey)
if (albumIndex !== -1) {
dataList.value[albumIndex].photos = data.photos as typeof dataList.value[number]['photos']
dataList.value[albumIndex].locked = false
}
currentUnlockAlbum.value = null
if (albumIndex !== -1) {
handleOpenPhotoViewer(dataList.value[albumIndex])
}
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱相册' })
handleRestoreUnlockedAlbums()
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-[144rpx]" style="background: linear-gradient(-135deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%));">
<view v-if="loading !== 'success'" class="loading-wrap box-border h-[60vh] w-screen flex flex-col items-center justify-center p-9">
<view v-if="loading === 'loading'" class="loading">
<view class="loadig-text mt-7 text-[28rpx] text-[#56bbf9]">
相册正在努力加载中啦~
</view>
</view>
<view v-else class="loading-error w-full">
<wd-empty description="啊偶,加载失败了呢~">
<wd-button size="small" plain type="danger" @click="handleGetData()">
刷新试试
</wd-button>
</wd-empty>
</view>
</view>
<!-- 内容区域 -->
<view v-else class="app-page-content">
<view v-if="dataList.length === 0" class="h-[60vh] w-full flex items-center justify-center content-empty">
<wd-empty description="相册暂时还没有数据~">
<wd-button size="small" plain type="primary" @click="handleGetData()">
刷新试试
</wd-button>
</wd-empty>
</view>
<!-- 相册列表(两列网格) -->
<view v-else class="album-list box-border flex flex-wrap px-6">
<view v-for="(item, index) in dataList" :key="index" class="album-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm" :class="index % 2 === 0 ? 'mr-6 w-[calc((100%-24rpx)/2)]' : 'w-[calc((100%-24rpx)/2)]'" @click="handleOnAlbumClick(item)">
<view class="album-cover-wrap relative h-[320rpx] w-full">
<image class="album-cover h-full w-full" :src="item.image" mode="aspectFill" lazy-load />
<view v-if="item.locked && !unlockedAlbums[item.name || '']" class="album-lock-mask absolute left-0 top-0 h-full w-full flex flex-col items-center justify-center bg-black/45">
<view class="lock-icon text-[64rpx]">
🔒
</view>
<view class="lock-tip mt-3 text-[26rpx] text-white">
已加密
</view>
</view>
</view>
<view class="album-info box-border p-5">
<view class="album-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#333] font-bold">
{{ item.displayName }}
</view>
<view class="album-count mt-1 text-[24rpx] text-[#999]">
{{ item.photoCount || 0 }} 张照片
</view>
</view>
</view>
</view>
</view>
<!-- 密码解锁弹窗 -->
<uh-album-unlock-modal
v-if="currentUnlockAlbum"
:show="showUnlockModal"
:album-name="unlockAlbumName"
:album-key="unlockAlbumKey"
@update:show="showUnlockModal = $event"
@success="handleOnUnlockSuccess"
/>
<!-- 相册图片查看弹窗 -->
<uh-album-photo-viewer
v-if="currentViewerAlbum"
:show="showPhotoViewer"
:album-name="viewerAlbumName"
:photos="viewerPhotos"
:loading="viewerLoading"
@update:show="showPhotoViewer = $event"
/>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+281
View File
@@ -0,0 +1,281 @@
<script lang="ts" setup>
/**
* 恋爱故事页(源自旧项目 pagesA/love/journey.vue,新建复刻)
* 时间轴展示恋爱故事,点击查看故事详情弹窗
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getLoveStories } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import type { ILoveStory } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '恋爱故事',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const scrollTop = ref(0)
const stories = ref<ILoveStory[]>([])
const showDetail = ref(false)
const currentStory = ref<ILoveStory>({})
const currentStoryHtml = ref('')
const storyImageIndex = ref(0)
/* ---------------- 计算属性 ---------------- */
/** 弹窗故事图片(预处理路径) */
const currentStoryImages = computed(() => {
const images = (currentStory.value as unknown as { spec?: { images?: string[] } }).spec?.images || []
return images.map(img => checkImageUrl(img || ''))
})
/* ---------------- 数据加载 ---------------- */
async function handleGetStories() {
loading.value = 'loading'
try {
const res = await getLoveStories({})
if (res.data && (res.data as unknown as { items?: unknown[] }).items && ((res.data as unknown as { items: unknown[] }).items.length > 0)) {
// 按 priority 排序
stories.value = ((res.data as unknown as { items: ILoveStory[] }).items).sort((a, b) => {
const priorityA = (a as unknown as { spec?: { priority?: number } }).spec?.priority || 0
const priorityB = (b as unknown as { spec?: { priority?: number } }).spec?.priority || 0
return priorityB - priorityA
})
loading.value = 'success'
}
else {
// 降级:从旧配置读取单条故事
handleLoadFromLegacy()
}
}
catch (e) {
console.error('获取故事失败', e)
handleLoadFromLegacy()
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
function handleLoadFromLegacy() {
const appConfigs = appConfigStore.configs
const loveModuleConfig = appConfigs.loveConfig as { ourStory?: { content?: string } } | undefined
if (loveModuleConfig?.ourStory?.content) {
stories.value = [{
name: 'legacy-story',
spec: {
title: '我们的故事',
content: loveModuleConfig.ourStory.content,
date: '',
images: [],
},
}]
loading.value = 'success'
return
}
stories.value = []
loading.value = 'success'
}
/* ---------------- 交互 ---------------- */
function handleOnStoryClick(story: ILoveStory) {
currentStory.value = story
currentStoryHtml.value = (story as unknown as { spec?: { content?: string } }).spec?.content || ''
storyImageIndex.value = 0
showDetail.value = true
}
function handleOnStoryImageChange(e: { detail: { current: number } }) {
storyImageIndex.value = e.detail.current
}
/** 时间轴封面图:最多 3 张,预处理路径 */
function storyCoverImages(story: ILoveStory): string[] {
const images = (story as unknown as { spec?: { images?: string[] } }).spec?.images || []
return images.slice(0, 3).map(img => checkImageUrl(img || ''))
}
/** 预览时间轴封面图 */
function handlePreviewStoryImages(story: ILoveStory, index: number) {
const images = (story as unknown as { spec?: { images?: string[] } }).spec?.images || []
const urls = images.map(img => checkImageUrl(img || ''))
if (urls.length === 0)
return
uni.previewImage({ current: urls[index], urls })
}
function handlePreviewImage(index: number) {
const images = (currentStory.value as unknown as { spec?: { images?: string[] } }).spec?.images || []
const urls = images.map(img => checkImageUrl(img || ''))
if (urls.length > 0) {
uni.previewImage({ current: urls[index], urls })
}
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱故事' })
handleGetStories()
})
onPullDownRefresh(() => {
handleGetStories()
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen p-6 pb-[144rpx]" style="background: linear-gradient(-45deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%)); color: rgb(26 26 26);">
<view v-if="loading === 'loading'" class="loading-wrap box-border h-[60vh] w-full flex items-center justify-center p-9">
<view class="loadig-text mt-7 text-[28rpx] text-[#56bbf9]">
故事正在努力加载中啦~
</view>
</view>
<view v-else-if="loading === 'error'" class="loading-wrap box-border h-[60vh] w-full flex items-center justify-center p-9">
<wd-empty description="啊偶,加载失败了呢~">
<wd-button size="small" plain type="danger" @click="handleGetStories()">
刷新试试
</wd-button>
</wd-empty>
</view>
<view v-else class="content-wrap">
<!-- 空状态 -->
<view v-if="stories.length === 0" class="empty-state h-[60vh] w-full flex items-center justify-center">
<wd-empty description="还没有故事,等待你们来书写...">
<wd-button size="small" plain type="primary" @click="handleGetStories()">
刷新试试
</wd-button>
</wd-empty>
</view>
<!-- 时间轴 -->
<view v-else class="timeline relative pl-10">
<view v-for="(story, index) in stories" :key="String((story as unknown as { name?: string })?.name ?? index)" class="timeline-item relative pb-10" @click="handleOnStoryClick(story)">
<view class="timeline-dot absolute left-[-32rpx] top-4 z-2 h-5 w-5 rounded-full" style="background-color: #f88ca2; box-shadow: 0 0 0 6rpx rgb(248 140 162 / 20%);" />
<view class="timeline-card rounded-xl bg-white p-6 shadow-sm">
<view v-if="(story as unknown as { spec?: { date?: string } }).spec?.date" class="timeline-date mb-2 text-[24rpx] text-[#f88ca2]">
{{ (story as unknown as { spec?: { date?: string } }).spec?.date }}
</view>
<view class="timeline-title text-[32rpx] text-[#333] font-bold">
{{ (story as unknown as { spec?: { title?: string } }).spec?.title || '' }}
</view>
<view v-if="(story as unknown as { spec?: { location?: string } }).spec?.location" class="timeline-location mt-2 flex items-center text-[24rpx] text-[#999]">
<text class="location-text ml-1">{{ (story as unknown as { spec?: { location?: string } }).spec?.location }}</text>
</view>
<view
v-if="(story as unknown as { spec?: { images?: string[] } }).spec?.images?.length"
class="timeline-covers mt-4 flex flex-wrap gap-2"
>
<view
v-for="(img, imgIndex) in storyCoverImages(story)"
:key="imgIndex"
class="timeline-cover h-[180rpx] w-[calc((100%-16rpx)/3)] overflow-hidden rounded-lg"
@click.stop="handlePreviewStoryImages(story, imgIndex)"
>
<image class="timeline-cover-img h-full w-full" :src="img" mode="aspectFill" lazy-load />
</view>
<view
v-if="((story as unknown as { spec?: { images?: string[] } }).spec?.images?.length || 0) > 3"
class="timeline-cover timeline-cover-more h-[180rpx] w-[calc((100%-16rpx)/3)] flex items-center justify-center bg-black/50"
@click.stop="handleOnStoryClick(story)"
>
<text class="more-text text-[32rpx] text-white font-bold">
+{{ ((story as unknown as { spec?: { images?: string[] } }).spec?.images?.length || 0) - 3 }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="to-top-btn fixed bottom-[160rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<!-- 故事详情弹窗 -->
<wd-popup v-model="showDetail" position="center" custom-style="width:90vw;height:80vh;border-radius:12rpx;">
<view class="story-detail h-full w-full flex flex-col overflow-hidden rounded-xl bg-white">
<view class="story-detail-header box-border shrink-0 border-b border-black/5 px-7 py-6">
<view class="story-detail-title text-[32rpx] text-[#333] font-bold">
{{ (currentStory as unknown as { spec?: { title?: string } }).spec?.title || '' }}
</view>
<view
v-if="(currentStory as unknown as { spec?: { date?: string; location?: string } }).spec?.date
|| (currentStory as unknown as { spec?: { date?: string; location?: string } }).spec?.location"
class="story-detail-meta mt-2 flex items-center text-[24rpx] text-[#999]"
>
<text v-if="(currentStory as unknown as { spec?: { date?: string } }).spec?.date" class="story-detail-date">
{{ (currentStory as unknown as { spec?: { date?: string } }).spec?.date }}
</text>
<text v-if="(currentStory as unknown as { spec?: { location?: string } }).spec?.location" class="story-detail-location ml-6">
{{ (currentStory as unknown as { spec?: { location?: string } }).spec?.location }}
</text>
</view>
</view>
<!-- 故事图片:多图 swiper 轮播 -->
<view v-if="currentStoryImages.length > 0" class="story-images shrink-0">
<swiper
v-if="currentStoryImages.length > 1"
class="story-images-swiper h-[360rpx] w-full"
circular
indicator-dots
indicator-color="rgba(255,255,255,0.4)"
indicator-active-color="#f88ca2"
:current="storyImageIndex"
@change="handleOnStoryImageChange"
>
<swiper-item v-for="(img, imgIndex) in currentStoryImages" :key="imgIndex" class="story-images-item h-full w-full">
<image :src="img" mode="aspectFill" class="story-image h-full w-full" @click="handlePreviewImage(imgIndex)" />
</swiper-item>
</swiper>
<image v-else :src="currentStoryImages[0]" mode="aspectFill" class="story-image story-image-single h-[360rpx] w-full" @click="handlePreviewImage(0)" />
</view>
<scroll-view scroll-y class="story-detail-content box-border min-h-0 flex-1 px-7 py-6">
<view class="story-html text-[28rpx] text-[#333] leading-[1.8]" v-html="currentStoryHtml" />
</scroll-view>
<view class="story-detail-close box-border shrink-0 border-t border-black/5 px-7 py-5">
<text class="close-text block h-20 rounded-[40rpx] text-center text-[30rpx] text-white font-bold" style="background: linear-gradient(135deg, #f88ca2, #ff6b9d); line-height: 80rpx;" @click="showDetail = false">关闭</text>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped lang="scss">
.timeline {
.timeline-item {
&::before {
content: '';
position: absolute;
left: -20rpx;
top: 12rpx;
bottom: 0;
width: 2rpx;
background-color: rgb(248 140 162 / 40%);
}
&:last-child::before {
display: none;
}
}
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<script lang="ts" setup>
/**
* 恋爱清单页(源自旧项目 pagesA/love/list.vue,新建复刻)
* 恋爱清单卡片列表(未开始/进行中/已完成),展开查看详情与回忆图片
*/
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getLoveDailyItems } from '@/api/uni-halo'
import { checkImageUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '恋爱清单',
enablePullDownRefresh: true,
},
})
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const list = ref<(ILoveItem & { open: boolean })[]>([])
interface ILoveItem {
name?: string
title?: string
content?: string
status?: 'wait' | 'doing' | 'complete'
planDate?: string
completeDate?: string
completeRemark?: string
images?: string[]
[key: string]: unknown
}
/* ---------------- 数据加载 ---------------- */
async function handleGetList() {
loading.value = 'loading'
try {
const res = await getLoveDailyItems({})
if (res.data && (res.data as unknown as { items?: unknown[] }).items) {
list.value = ((res.data as unknown as { items: unknown[] }).items as unknown as {
spec?: ILoveItem
metadata?: { name?: string }
name?: string
}[]).map(item => ({
...item.spec,
name: item.metadata?.name || item.name || '',
open: false,
}))
loading.value = 'success'
}
else {
list.value = []
loading.value = 'success'
}
}
catch (e) {
console.error('获取清单失败', e)
loading.value = 'error'
uni.showToast({ icon: 'none', title: '加载失败,请下拉刷新重试!' })
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
/* ---------------- 交互 ---------------- */
function handleOnItemOpen(item: ILoveItem & { open: boolean }) {
item.open = !item.open
}
function handlePreviewImages(images: string[] | undefined, index: number) {
const urls = (images || []).map(img => checkImageUrl(img || ''))
if (urls.length === 0)
return
uni.previewImage({ current: urls[index], urls })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱清单' })
handleGetList()
})
onPullDownRefresh(() => {
handleGetList()
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen p-6 pb-[144rpx]" style="background: linear-gradient(135deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%));">
<view v-if="loading === 'loading'" class="loading-wrap box-border h-[60vh] w-full flex items-center justify-center p-9">
<view class="loadig-text mt-7 text-[28rpx] text-[#56bbf9]">
清单正在努力加载中啦~
</view>
</view>
<view v-else-if="loading === 'error'" class="loading-wrap box-border h-[60vh] w-full flex items-center justify-center p-9">
<wd-empty description="啊偶,加载失败了呢~">
<wd-button size="small" plain type="danger" @click="handleGetList()">
刷新试试
</wd-button>
</wd-empty>
</view>
<view v-else class="list-wrap w-full">
<view v-if="list.length === 0" class="list h-[60vh] flex flex-col items-center justify-center">
<wd-empty description="暂时还没有恋爱清单,快去制定你们的恋爱清单吧~">
<wd-button size="small" plain type="primary" @click="handleGetList()">
刷新试试
</wd-button>
</wd-empty>
</view>
<view v-else class="list">
<view class="list-tip mb-7 w-full text-center text-[26rpx] text-[#999]">
看看我们的恋爱清单都完成了哪些吧
</view>
<block v-for="(item, index) in list" :key="item.name || index">
<view class="card mb-6 box-border w-full flex flex-col items-center rounded-3xl bg-white p-6 shadow-sm">
<view class="head box-border w-full flex items-center" @click="handleOnItemOpen(item)">
<view class="status w-[100rpx] flex">
<view v-if="item.status === 'wait'" class="text h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #ffc6ba; color: #55423b;">
未开始
</view>
<view v-else-if="item.status === 'doing'" class="text doing h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #ffe9a8; color: #55423b;">
进行中
</view>
<view v-else class="text finish h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #bfe9ef; color: #55423b;">
已完成
</view>
</view>
<view class="title box-border w-0 flex-1 px-7">
<view class="title-name text-[30rpx] text-[#333] font-bold">
{{ item.title }}
</view>
<view v-if="item.content" class="title-desc mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#555]">
{{ item.content }}
</view>
</view>
<view class="actions w-[50rpx]">
<text class="icon inline-block h-[45rpx] w-[45rpx] rounded-full bg-black/20 text-center text-[32rpx] font-bold leading-[45rpx]">{{ item.open ? '-' : '+' }}</text>
</view>
</view>
<view v-if="item.open" class="body mt-6 box-border w-full rounded-3xl bg-black/5 px-6 pb-3 pt-6 text-[26rpx]">
<view v-if="item.planDate" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
计划时间
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.planDate || '-' }}
</view>
</view>
<view v-if="item.completeDate" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
完成时间
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.completeDate || '-' }}
</view>
</view>
<view v-if="item.completeRemark" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
完成感想
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.completeRemark || '-' }}
</view>
</view>
<view v-if="item.images && item.images.length > 0" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
回忆图片
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
<view class="images flex flex-wrap">
<view
v-for="(img, imgIndex) in item.images"
:key="imgIndex"
class="image mb-3 mr-3 h-[180rpx] w-[calc((100%-24rpx)/3)] overflow-hidden rounded-lg"
@click="handlePreviewImages(item.images, imgIndex)"
>
<image class="image-src h-full w-full" :src="checkImageUrl(img)" mode="aspectFill" lazy-load />
</view>
</view>
</view>
</view>
</view>
</view>
</block>
</view>
</view>
<view class="to-top-btn fixed bottom-[160rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<script lang="ts" setup>
/**
* 恋爱主页(源自旧项目 pagesA/love/love.vue,新建复刻)
* 情侣信息 + 恋爱计时 + 功能导航(恋爱故事/相册/清单)
*/
import { computed, onBeforeUnmount, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getLoveConfig } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '恋爱日记',
},
})
const appConfigStore = useAppConfigStore()
/* ---------------- 恋爱配置 ---------------- */
interface ILoveConfigPage {
enabled: boolean
loveDateTitle: string
loveDate: string
loveInfo: {
boyNickname: string
boyAvatar: string
girlNickname: string
girlAvatar: string
}
pageImages: {
bgImageUrl: string
waveImageUrl: string
heartImageUrl: string
}
ourStory: { enabled: boolean, iconUrl: string }
lovePhoto: { enabled: boolean, iconUrl: string }
loveDaily: { enabled: boolean, iconUrl: string }
[key: string]: unknown
}
const loveConfig = ref<ILoveConfigPage>({
enabled: false,
loveDateTitle: '',
loveDate: '',
loveInfo: {
boyNickname: '',
boyAvatar: '',
girlNickname: '',
girlAvatar: '',
},
pageImages: {
bgImageUrl: '',
waveImageUrl: '',
heartImageUrl: '',
},
ourStory: { enabled: false, iconUrl: '' },
lovePhoto: { enabled: false, iconUrl: '' },
loveDaily: { enabled: false, iconUrl: '' },
})
const loveDayCount = ref({ d: 0, h: 0, m: 0, s: 0 })
let loveDayTimer: ReturnType<typeof setTimeout> | null = null
const navList = ref<{ key: string, use: boolean, iconImageUrl: string, title: string, desc: string }[]>([])
/* ---------------- 计算属性 ---------------- */
const loveWrapStyle = computed(() => ({
backgroundImage: `url(${checkImageUrl(loveConfig.value.pageImages.bgImageUrl)})`,
}))
/* ---------------- 数据加载 ---------------- */
async function handleGetLoveConfig() {
try {
const loveConfigRes = await getLoveConfig()
if (loveConfigRes) {
loveConfig.value = {
...loveConfig.value,
...loveConfigRes,
}
}
// 同时从 getConfigs 获取模块开关和图片配置
const appConfigs = appConfigStore.configs
const loveModuleConfig = appConfigs.loveConfig as Partial<ILoveConfigPage> | undefined
if (loveModuleConfig) {
loveConfig.value = {
...loveConfig.value,
pageImages: loveModuleConfig.pageImages || loveConfig.value.pageImages,
ourStory: loveModuleConfig.ourStory || loveConfig.value.ourStory,
lovePhoto: loveModuleConfig.lovePhoto || loveConfig.value.lovePhoto,
loveDaily: loveModuleConfig.loveDaily || loveConfig.value.loveDaily,
}
}
initList()
handleInitLoveDayCount()
}
catch (e) {
console.error('获取恋爱配置失败', e)
// 降级:从旧配置读取
const appConfigs = appConfigStore.configs
const loveModuleConfig = appConfigs.loveConfig as ILoveConfigPage | undefined
if (loveModuleConfig) {
loveConfig.value = loveModuleConfig
initList()
handleInitLoveDayCount()
}
}
}
function initList() {
const configs = loveConfig.value
navList.value = [
{
key: 'journey',
use: configs.ourStory.enabled,
iconImageUrl: configs.ourStory.iconUrl,
title: '恋爱故事',
desc: '我们一起度过的那些经历',
},
{
key: 'album',
use: configs.lovePhoto.enabled,
iconImageUrl: configs.lovePhoto.iconUrl,
title: '恋爱相册',
desc: '定格了我们的那些小美好',
},
{
key: 'list',
use: configs.loveDaily.enabled,
iconImageUrl: configs.loveDaily.iconUrl,
title: '恋爱清单',
desc: '你我之间的约定我们都在努力实现',
},
]
}
/* ---------------- 恋爱计时 ---------------- */
function handleInitLoveDayCount() {
if (loveDayTimer) {
clearTimeout(loveDayTimer)
}
const countDownFn = () => {
loveDayTimer = setTimeout(countDownFn, 1000)
const formatStartDate = loveConfig.value.loveDate.replace(/-/g, '/')
const start = new Date(formatStartDate)
const now = new Date()
const T = now.getTime() - start.getTime()
const i = 24 * 60 * 60 * 1000
const d = T / i
const D = Math.floor(d)
const h = (d - D) * 24
const H = Math.floor(h)
const m = (h - H) * 60
const M = Math.floor(m)
const s = (m - M) * 60
const S = Math.floor(s)
loveDayCount.value = { d: D, h: H, m: M, s: S }
}
countDownFn()
}
/* ---------------- 跳转 ---------------- */
function handleToPage(pageName: string) {
uni.navigateTo({
url: `/pages-blog/love/${pageName}`,
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱日记' })
handleGetLoveConfig()
})
onBeforeUnmount(() => {
if (loveDayTimer) {
clearTimeout(loveDayTimer)
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen">
<!-- 情侣信息 -->
<view class="lover-wrap relative h-[50vh] w-screen flex items-center justify-center" :style="[loveWrapStyle]">
<view class="lover-card absolute left-1/2 top-[58%] z-2 w-[90vw] flex items-center justify-around rounded-xl -translate-x-1/2 -translate-y-1/2">
<view class="boy">
<image class="avatar box-border h-[180rpx] w-[180rpx] border-8 rounded-full" :style="{ borderColor: 'rgb(58 184 228 / 70%)' }" :src="checkAvatarUrl(loveConfig.loveInfo.boyAvatar)" mode="aspectFit" />
<view class="name mt-2 text-center text-[32rpx] text-white font-bold tracking-[2rpx]">
{{ loveConfig.loveInfo.boyNickname }}
</view>
</view>
<image class="like h-[120rpx] w-[120rpx]" :src="checkImageUrl(loveConfig.pageImages.heartImageUrl)" mode="scaleToFill" />
<view class="girl">
<image class="avatar box-border h-[180rpx] w-[180rpx] border-8 rounded-full" :style="{ borderColor: 'rgb(245 122 179 / 70%)' }" :src="checkAvatarUrl(loveConfig.loveInfo.girlAvatar)" mode="aspectFit" />
<view class="name mt-2 text-center text-[32rpx] text-white font-bold tracking-[2rpx]">
{{ loveConfig.loveInfo.girlNickname }}
</view>
</view>
</view>
<image class="wave-image absolute bottom-0 left-0 h-[120rpx] w-full" :src="checkImageUrl(loveConfig.pageImages.waveImageUrl)" mode="scaleToFill" />
</view>
<!-- 恋爱记时 -->
<view class="love-time-wrap mt-20 w-screen flex flex-col items-center justify-center">
<view class="title text-[42rpx] text-[#333] font-bold">
{{ loveConfig.loveDateTitle }}
</view>
<view class="content mt-6 flex items-center justify-center">
<text class="text text-[28rpx]">
<text class="number mx-2 text-[46rpx] text-[#f83856] font-bold">{{ loveDayCount.d }}</text>
</text>
<text class="text text-[28rpx]">
<text class="number mx-2 text-[46rpx] text-[#f83856] font-bold">{{ loveDayCount.h }}</text>
小时
</text>
<text class="text text-[28rpx]">
<text class="number mx-2 text-[46rpx] text-[#f83856] font-bold">{{ loveDayCount.m }}</text>
分钟
</text>
<text class="text text-[28rpx]">
<text class="number mx-2 text-[46rpx] text-[#f83856] font-bold">{{ loveDayCount.s }}</text>
</text>
</view>
</view>
<!-- 功能导航 -->
<view class="list-wrap mt-[75rpx] box-border flex flex-col items-center justify-center px-9">
<block v-for="(nav, index) in navList" :key="index">
<view v-if="nav.use" class="mb-8 box-border list-item w-full flex items-center justify-around rounded-[50rpx] bg-white px-8 py-7 shadow-sm" :class="`list-item-${index + 1}`" @click="handleToPage(nav.key)">
<view class="left h-[120rpx] w-[120rpx]">
<image class="icon h-full w-full" :src="checkImageUrl(nav.iconImageUrl)" mode="aspectFit" />
</view>
<view class="right box-border flex flex-1 flex-col justify-center pl-10">
<view class="name text-[32rpx] text-[#333] font-bold">
{{ nav.title }}
</view>
<view class="desc mt-2 text-[26rpx] text-[#777]">
{{ nav.desc }}
</view>
</view>
</view>
</block>
</view>
</view>
</template>
<style scoped lang="scss">
.app-page {
background: linear-gradient(
-45deg,
rgb(247 149 51 / 10%),
rgb(243 112 85 / 10%) 15%,
rgb(239 78 123 / 10%) 30%,
rgb(161 102 171 / 10%) 44%,
rgb(80 115 184 / 10%) 58%,
rgb(16 152 173 / 10%) 72%,
rgb(7 179 155 / 10%) 86%,
rgb(109 186 130 / 10%)
);
}
.lover-wrap {
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
&::before {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
content: '';
background-color: rgb(255 255 255 / 10%);
z-index: 0;
backdrop-filter: blur(4rpx);
overflow: hidden;
}
&::after {
content: '';
position: absolute;
left: 0;
bottom: -60rpx;
width: 100vw;
height: 60rpx;
background-image: linear-gradient(to bottom, rgb(255 255 255), rgb(255 255 255 / 0%));
}
.like {
animation: likeani 1s ease-in-out infinite;
}
.wave-image {
mix-blend-mode: screen;
}
}
/* 列表项漂浮动画(无法用 UnoCSS 表达) */
.list-item {
&:nth-child(1) {
animation: listItemAni1 3s ease-in-out infinite;
}
&:nth-child(2) {
animation: listItemAni1 3s ease-in-out infinite;
animation-delay: 1.5s;
}
&:nth-child(3) {
animation: listItemAni1 3s ease-in-out infinite;
animation-delay: 2s;
}
}
@keyframes likeani {
0% {
transform: scale(1);
}
25% {
transform: scale(1.2);
}
50% {
transform: scale(1.1);
}
75% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
@keyframes listItemAni1 {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-10rpx);
}
100% {
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,299 @@
<script lang="ts" setup>
/**
* 瞬间详情页(源自旧项目 pagesA/moment-detail,新建复刻)
* 展示瞬间内容(mp-html) + 图片/音频/视频附件
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getMomentByName } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
import { markdownConfig } from '@/config/markdown'
import type { IMoment } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '瞬间详情',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
const startConfig = computed(() => haloConfigs.value.appConfig?.startConfig as { title?: string } | undefined)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryName = ref('')
const moment = ref<(IMoment & {
images?: { type?: string, url: string }[]
videos?: { id?: string, url: string }[]
audios?: { type?: string, url: string }[]
spec: IMoment['spec'] & { newHtml?: string }
}) | null>(null)
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null)
/** 移除 tag 链接 */
function removeTagLinksCompletely(htmlString: string): string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
return htmlString.replace(regex, '')
}
function tagColor(): string {
if (!calcUseTagRandomColor.value)
return 'blue'
return randomTagColor()
}
function formatTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
try {
const res = await getMomentByName(queryName.value)
uni.setNavigationBarTitle({ title: '瞬间详情' })
const medium = (res.data.spec as unknown as { medium?: { type?: string, url: string }[] }).medium || []
const tempResult = {
...res.data,
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 || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
audios: medium.filter(x => x.type === 'AUDIO'),
}
moment.value = tempResult
loading.value = 'success'
nextTick(() => {
createVideoContexts(tempResult.videos || [])
})
}
catch (err) {
console.error('获取瞬间详情失败', err)
loading.value = 'error'
}
finally {
uni.stopPullDownRefresh()
}
}
/* ---------------- 视频互斥 ---------------- */
function createVideoContexts(videos: { id?: string }[]) {
stopAllVideos()
videos.forEach((item) => {
if (item.id) {
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
}
})
}
function stopAllVideos(excludesVideoId: string | null = null) {
Object.keys(videoContexts.value).forEach((videoId) => {
if (!excludesVideoId || excludesVideoId !== videoId) {
videoContexts.value[videoId]?.pause()
}
})
}
function onVideoPlay(videoId: string) {
currentVideoId.value = videoId
stopAllVideos(videoId)
}
function onVideoPause(videoId: string) {
if (currentVideoId.value === videoId) {
currentVideoId.value = null
}
}
function onVideoEnded() {
currentVideoId.value = null
}
/* ---------------- 交互 ---------------- */
function handlePreview(index: number, list: { url: string }[]) {
uni.previewImage({
current: index,
urls: list.map(item => item.url),
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
uni.setNavigationBarTitle({ title: '瞬间加载中...' })
queryName.value = options?.name || ''
handleGetData()
})
onPullDownRefresh(() => {
videoContexts.value = {}
currentVideoId.value = null
handleGetData()
})
onShareAppMessage(() => ({
path: `/pages-blog/moment-detail/moment-detail?name=${moment.value?.metadata.name}`,
title: moment.value?.spec.owner?.displayName || '',
}))
onShareTimeline(() => ({
title: moment.value?.spec.owner?.displayName || '',
query: moment.value ? `name=${moment.value.metadata.name}` : '',
}))
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap h-screen bg-white px-6">
<wd-skeleton :row="3" :animated="true" />
</view>
<block v-else>
<view v-if="moment" class="moment-card flex flex-col gap-6 p-6">
<!-- 用户信息 -->
<view class="card flex items-center rounded-xl bg-white p-6 shadow-sm">
<image class="avatar h-[80rpx] w-[80rpx] shrink-0 rounded-full" :src="moment.spec.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 }}
</view>
<view class="release-time mt-1.5 text-[24rpx] text-[#666]">
{{ formatTime(moment.spec.releaseTime) }}
</view>
</view>
</view>
<!-- 标签 -->
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<text class="tags-label text-[26rpx] text-[#606266]">标签列表</text>
<text v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="tag mr-4 text-[24rpx]" :style="{ color: tagColor() }">
{{ tag }}
</text>
</view>
<!-- 内容 -->
<view class="card overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</view>
<!-- 图片附件 -->
<view v-if="moment.images && moment.images.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
图片附件
</view>
<view class="images flex flex-wrap items-start pt-6" :class="`images-${moment.images.length}`">
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex" class="image-item box-border p-1.5" :class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
<image
mode="aspectFill"
class="image-src h-full w-full rounded-lg"
:src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])"
/>
</view>
</view>
</view>
<!-- 音频附件 -->
<view v-if="moment.audios && moment.audios.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
音频附件
</view>
<view class="audio-list flex flex-col gap-3 pt-3">
<uh-audio-player
v-for="audio in moment.audios"
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${startConfig?.title || bloggerInfo.nickname}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
</view>
<!-- 视频附件 -->
<view v-if="moment.videos && moment.videos.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
视频附件
</view>
<view class="video-list mt-6 w-full flex flex-col gap-3">
<video
v-for="(video, index) in moment.videos"
:id="`video_${video.id}`"
:key="index"
class="video-src h-[400rpx] w-full rounded-xl"
:src="video.url"
:show-mute-btn="true"
:controls="true"
:show-center-play-btn="true"
:enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')"
@pause="onVideoPause(video.id || '')"
@ended="onVideoEnded"
/>
</view>
</view>
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+263
View File
@@ -0,0 +1,263 @@
<script lang="ts" setup>
/**
* 应用设置页(源自旧项目 pagesA/setting,新建复刻)
* 布局设置(首页布局/文章卡片样式)+ 功能设置(瀑布流/友链简洁/圆头像/轮播指示器)+ 保存/恢复默认
*/
import { reactive, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { DefaultAppSettings } from '@/config/appSettings'
import { useSettingStore } from '@/store/setting'
import type { IAppSettings } from '@/config/appSettings'
definePage({
style: {
navigationBarTitleText: '应用设置',
},
})
const settingStore = useSettingStore()
/* ---------------- 状态 ---------------- */
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',
})
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',
})
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 handleHandleFormatSelect() {
const _homeLayout = handleFindObjInList(homeLayout.list, 'value', appSettings.layout.home)
homeLayout.selectLabel = _homeLayout.name
homeLayout.selectValue = _homeLayout.value
const _cardStyle = handleFindObjInList(articleCardStyle.list, 'value', appSettings.layout.cardType)
articleCardStyle.selectLabel = _cardStyle.name
articleCardStyle.selectValue = _cardStyle.value
const _dot = handleFindObjInList(dotPositionList, 'value', appSettings.banner.dotPosition)
dotPositionList.forEach((item) => {
item.checked = item.value === _dot.value
})
}
/* ---------------- 交互 ---------------- */
function handleOnHomeLayoutConfirm() {
const _select = handleFindObjInList(homeLayout.list, 'value', appSettings.layout.home)
homeLayout.selectLabel = _select.name
homeLayout.selectValue = _select.value
}
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
}
else {
isSaved.value = false
}
}, { 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)
})
</script>
<template>
<view class="app-page box-border min-h-screen pb-[140rpx]" style="background-color: #fafafd;">
<view v-if="!loading">
<!-- 布局设置 -->
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
<text class="title-text text-[30rpx] text-[#303133] font-bold">布局</text>
<text class="title-desc ml-3 text-[22rpx] text-[#999]">应用以及文章列表布局设置</text>
</view>
<view class="sheet-content">
<!-- 首页布局 -->
<view 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>
</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>
<wd-icon name="arrow-right" size="12px" color="#999" />
</view>
</view>
</view>
</view>
<!-- 功能设置 -->
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
<text class="title-text text-[30rpx] text-[#303133] font-bold">功能</text>
<text class="title-desc ml-3 text-[22rpx] text-[#999]">一些常用的功能性设置</text>
</view>
<view class="sheet-content">
<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 }}
</view>
</view>
</view>
</view>
</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">
返回
</wd-button>
</view>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.btn-bar {
:deep(wd-button) {
flex: 1;
}
}
</style>
+232
View File
@@ -0,0 +1,232 @@
<script lang="ts" setup>
/**
* 友链申请页(源自旧项目 pagesA/submit-link,新建复刻)
* 提交友链交换信息表单
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { submitLink } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '友链申请',
},
})
const appConfigStore = useAppConfigStore()
const haloPluginConfigs = computed(() => appConfigStore.configs.pluginConfig)
const blogDetail = computed(() => (haloPluginConfigs.value?.linksSubmitPlugin as {
blogName?: string
blogUrl?: string
blogLogo?: string
blogDesc?: string
} | undefined) || {})
const blogDetailPoupShow = ref(false)
const form = ref({
url: '',
name: '',
logo: '',
linkPageUrl: '',
email: '',
rssUrl: '',
description: '',
})
const calcBlogContent = computed(() => `
博客名称:${blogDetail.value.blogName}
博客地址:${blogDetail.value.blogUrl}
博客logo${checkAvatarUrl(blogDetail.value.blogLogo)}
博客简介:${blogDetail.value.blogDesc}
`)
function calcSiteThumbnail(val?: string): string {
if (!val)
return ''
const _val = val.endsWith('/') ? val : `${val}/`
return `https://image.thum.io/get/width/1000/crop/800/${_val}`
}
function checkIsUrl(url: string): boolean {
return /^https?:\/\//i.test(url)
}
function checkIsEmail(email: string): boolean {
return /^[\w.-]+@[\w-]+(?:\.[\w-]+)+$/.test(email)
}
async function handleHandle() {
if (!form.value.name.trim()) {
uni.showToast({ icon: 'none', title: '请输入网站名称!' })
return
}
if (!checkIsUrl(form.value.url)) {
uni.showToast({ icon: 'none', title: '请输入正确的网站地址!' })
return
}
if (form.value.logo && !checkIsUrl(form.value.logo)) {
uni.showToast({ icon: 'none', title: '请输入正确的Logo地址!' })
return
}
if (form.value.email && !checkIsEmail(form.value.email)) {
uni.showToast({ icon: 'none', title: '请输入正确的邮箱地址!' })
return
}
uni.showLoading({ title: '正在提交...' })
try {
const res = await submitLink({
name: form.value.name,
url: form.value.url,
logo: form.value.logo,
description: form.value.description,
email: form.value.email,
linkPageUrl: form.value.linkPageUrl,
rssUrl: form.value.rssUrl,
})
uni.hideLoading()
const code = res.code
const msg = res.data?.msg || res.data?.message || res.message || '提交成功'
uni.showToast({ icon: 'none', title: msg })
if (code === 200 || code === undefined) {
setTimeout(() => {
uni.navigateTo({
url: '/pages-blog/friend-links/friend-links',
})
}, 1000)
}
}
catch (err) {
console.error(err)
uni.hideLoading()
uni.showToast({ icon: 'none', title: '提交失败,请重试!' })
}
}
function handleCopyLink() {
uni.setClipboardData({
data: calcBlogContent.value,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: '复制成功!' })
},
fail: () => {
uni.showToast({ icon: 'none', title: '复制失败!' })
},
})
}
onLoad(() => {
uni.setNavigationBarTitle({ title: '友链申请' })
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen bg-[#fafafd] p-8">
<!-- 博客详情卡片 -->
<view class="blog-coupon mb-6 flex items-center rounded-xl p-6" style="background: linear-gradient(135deg, #2196f3, #64b5f6);" @click="blogDetailPoupShow = true">
<image class="coupon-img h-[80rpx] w-[80rpx] shrink-0 rounded-xl" :src="checkAvatarUrl(blogDetail.blogLogo)" mode="aspectFill" />
<view class="coupon-info ml-5 flex-1">
<view class="coupon-title text-[30rpx] text-white font-bold">
{{ blogDetail.blogName }}
</view>
<view class="coupon-desc mt-1 text-[24rpx] text-white/80">
{{ blogDetail.blogDesc }}
</view>
</view>
<view class="coupon-btn border-2 border-white/60 rounded-3xl px-5 py-1 text-[24rpx] text-white">
友链详情
</view>
</view>
<!-- 友链信息提交表单 -->
<view class="form-wrap rounded-xl bg-white p-6 shadow-sm">
<view class="form-title mb-6 text-[26rpx] font-bold">
友链信息提交
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">名称</text>
<input v-model="form.name" class="input flex-1 text-[26rpx]" placeholder="请输入网站名称">
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">网址</text>
<input v-model="form.url" class="input flex-1 text-[26rpx]" placeholder="请输入网站地址">
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">Logo</text>
<input v-model="form.logo" class="input flex-1 text-[26rpx]" placeholder="请输入网站Logo">
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">邮箱</text>
<input v-model="form.email" class="input flex-1 text-[26rpx]" placeholder="请输入邮箱">
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">友链页面</text>
<input v-model="form.linkPageUrl" class="input flex-1 text-[26rpx]" placeholder="请输入友链页面地址">
</view>
<view class="form-tip py-1 pl-[160rpx] text-[22rpx] text-[#999]">
贵站友情链接页面地址即包含本站链接的页面
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">RSS地址</text>
<input v-model="form.rssUrl" class="input flex-1 text-[26rpx]" placeholder="请输入RSS地址">
</view>
<view class="form-tip py-1 pl-[160rpx] text-[22rpx] text-[#999]">
用于抓取文章
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">网站描述</text>
<textarea v-model="form.description" class="textarea h-[100rpx] flex-1 text-[26rpx]" :maxlength="30" placeholder="请输入网站描述,不超过30字符" />
</view>
<view class="submit-btn mt-6">
<wd-button type="primary" block size="medium" @click="handleHandle">
提交数据
</wd-button>
<view class="submit-tip py-8 text-center text-[24rpx] text-[#999]">
友链申请
</view>
</view>
</view>
<!-- 博客详情弹窗 -->
<wd-popup v-model="blogDetailPoupShow" position="center" custom-style="width:640rpx;border-radius:12rpx;">
<view class="poup p-9">
<view class="info flex">
<image class="poup-logo h-[140rpx] w-[140rpx] rounded-3xl" :src="checkAvatarUrl(blogDetail.blogLogo)" mode="aspectFill" />
<view class="info-detail ml-6 flex flex-1 flex-col justify-center">
<view class="poup-name text-[34rpx] font-bold">
{{ blogDetail.blogName }}
</view>
<view class="poup-tag mt-2.5 text-[24rpx] text-[#999]">
{{ blogDetail.blogDesc }}
</view>
</view>
</view>
<view class="poup-desc mt-6 whitespace-pre-wrap text-[28rpx] text-[#555] leading-[1.8]">
<text>{{ calcBlogContent }}</text>
</view>
<image v-if="blogDetail.blogUrl" class="poup-img mt-6 h-[320rpx] w-[568rpx] rounded-xl" :src="calcSiteThumbnail(blogDetail.blogUrl)" mode="aspectFill" />
<view class="poup-link my-6 flex justify-center gap-6">
<wd-button size="small" plain type="primary" @click="handleCopyLink">
复制友链交换信息
</wd-button>
<wd-button size="small" plain @click="blogDetailPoupShow = false">
关闭
</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts" setup>
/**
* 标签详情页(源自旧项目 pagesA/tag-detail,新建复刻)
* 展示某标签下的文章列表,分页加载
*/
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getPostByTagName } from '@/api/halo'
import type { IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '标签详情',
enablePullDownRefresh: true,
},
})
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 0 })
const name = ref('')
const pageTitle = ref('加载中...')
const dataList = ref<IPost[]>([])
const hasNext = ref(false)
const isLoadMore = ref(false)
const loadMoreText = ref('')
async function handleGetData() {
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = '加载中...'
try {
const res = await getPostByTagName(name.value, { ...queryParams.value })
uni.setNavigationBarTitle({ title: `${pageTitle.value} (共${res.data.total}篇)` })
hasNext.value = res.data.hasNext
dataList.value = isLoadMore.value
? dataList.value.concat(res.data.items)
: res.data.items
loadMoreText.value = res.data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
setTimeout(() => {
loading.value = 'success'
}, 500)
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 500)
}
}
function handleToArticleDetail(article: IPost) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
onLoad((options) => {
name.value = options?.name || ''
pageTitle.value = options?.title || '标签详情'
handleGetData()
})
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 0
handleGetData()
})
onReachBottom(() => {
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col py-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<view v-if="dataList.length === 0" class="empty h-[60vh] flex items-center justify-center">
<wd-empty description="该标签下暂无文章" />
</view>
<block v-else>
<uh-article-card
v-for="(article, index) in dataList"
:key="index"
:article="article"
@on-click="handleToArticleDetail"
/>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts" setup>
/**
* 文章标签页(源自旧项目 pagesA/tags,新建复刻)
*/
definePage({
style: {
navigationBarTitleText: '标签',
},
})
</script>
<template>
<view class="text-[30rpx] text-[#303133]">
文章标签页面
</view>
</template>
<style scoped>
.app-page {
/* 无额外样式 */
}
</style>
+416
View File
@@ -0,0 +1,416 @@
<script lang="ts" setup>
/**
* 投票详情页(源自旧项目 pagesA/vote-detail,新建复刻)
* 支持 single/multiple/pk 三种投票类型,已投票展示结果
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getVoteDetail, submitVote } from '@/api/uni-halo'
import { calcVotePercent, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IVote, IVoteOption } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '投票详情',
enablePullDownRefresh: true,
},
})
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const submitLoading = ref(false)
const pageTitle = ref('加载中...')
const safeAreaBottom = ref(24)
const name = ref('')
const detail = ref<unknown>(null)
const vote = ref<(IVote & {
spec?: {
title?: string
remark?: string
type?: string
maxVotes?: number
startDate?: string
endDate?: string
timeLimit?: string
canAnonymously?: boolean
options?: (IVoteOption & {
id?: string
title?: string
count?: number
checked?: boolean
isVoted?: boolean
disabled?: boolean
_uh_percent?: number
})[]
isVoted?: boolean
hasEnded?: boolean
disabled?: boolean
_uh_type?: string
_uh_state?: { state: string, color: string }
}
stats?: { voteCount?: number }
}) | null>(null)
const submitForm = ref<{ voteData: string[] }>({ voteData: [] })
/* ---------------- 计算属性 ---------------- */
const isVoted = computed(() => voteCacheUtil.has(name.value))
const isEnded = computed(() => vote.value?.spec?.hasEnded || false)
/* ---------------- 工具 ---------------- */
function formatTime(date?: string, fmt = 'yyyy-MM-dd HH:mm'): string {
// 与旧项目一致:yyyy-MM-dd HH:mm
return date ? formatTimeUtil({ d: date, f: fmt }) : ''
}
function showToast(content: string) {
uni.showToast({ icon: 'none', title: content, mask: true })
}
function handleCalcIsChecked(option: { id?: string }): boolean {
const data = voteCacheUtil.get(name.value)
if (!data)
return false
return data.selected.includes(option.id || '')
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
pageTitle.value = '加载中...'
try {
const res = await getVoteDetail(name.value)
const tempVote = res.data as typeof vote.value
if (tempVote) {
pageTitle.value = `投票详情(${VOTE_TYPES[(tempVote.spec?.type || 'SINGLE') as keyof typeof VOTE_TYPES] || tempVote.spec?.type}`
tempVote.spec = tempVote.spec || {}
tempVote.spec.isVoted = isVoted.value
tempVote.spec.disabled = isVoted.value
tempVote.spec._uh_type = VOTE_TYPES[(tempVote.spec.type || 'SINGLE') as keyof typeof VOTE_TYPES] || tempVote.spec.type
// 计算状态
const startTime = tempVote.spec.startDate ? new Date(tempVote.spec.startDate).getTime() : Date.now()
const endTime = tempVote.spec.endDate ? new Date(tempVote.spec.endDate).getTime() : Date.now()
const now = Date.now()
if (endTime < now) {
tempVote.spec._uh_state = { state: '已结束', color: 'red' }
tempVote.spec.hasEnded = true
}
else if (startTime > now) {
tempVote.spec._uh_state = { state: '未开始', color: 'orange' }
}
else {
tempVote.spec._uh_state = { state: '进行中', color: 'green' }
}
// 选项计算
tempVote.spec.options = (tempVote.spec.options || []).map((option) => {
const checked = handleCalcIsChecked(option)
return {
...option,
value: option.id,
label: option.title,
isVoted: isVoted.value,
checked,
disabled: isVoted.value,
_uh_percent: calcVotePercent(tempVote, option),
}
})
}
vote.value = tempVote
detail.value = res
setTimeout(() => {
loading.value = 'success'
}, 200)
}
catch (err) {
console.error(err)
loading.value = 'error'
pageTitle.value = '加载失败,请重试...'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
uni.setNavigationBarTitle({ title: pageTitle.value })
}, 200)
}
}
/* ---------------- 交互 ---------------- */
function handleSelectSingleOption(option: { id?: string }) {
if (vote.value?.spec?._uh_state?.state === '未开始') {
showToast('投票未开始')
return
}
if (vote.value?.spec?.hasEnded)
return
if (vote.value?.spec?.disabled)
return
vote.value!.spec!.options!.forEach((item) => {
item.checked = option.id === item.id
})
submitForm.value.voteData = vote.value!.spec!.options!.filter(x => x.checked).map(item => item.id || '')
}
function handleSelectCheckboxOption(option: { id?: string }) {
if (vote.value?.spec?._uh_state?.state === '未开始') {
showToast('投票未开始')
return
}
if (vote.value?.spec?.hasEnded)
return
if (vote.value?.spec?.disabled)
return
const checkedList = vote.value!.spec!.options!.filter(x => x.checked && x.id !== option.id)
if (vote.value?.spec?.type === 'multiple' && checkedList.length >= (vote.value.spec.maxVotes || 0)) {
showToast(`最多选择 ${vote.value.spec.maxVotes}`)
return
}
vote.value!.spec!.options!.forEach((item) => {
if (option.id === item.id) {
item.checked = !item.checked
}
})
submitForm.value.voteData = vote.value!.spec!.options!.filter(x => x.checked).map(item => item.id || '')
}
function handleSubmitTip(text: string) {
showToast(text)
}
async function handleSubmit() {
if (!vote.value?.spec?.canAnonymously) {
uni.showModal({
title: '提示',
content: '该投票不支持匿名,请到博主的 网站端 进行投票!',
cancelColor: '#666666',
cancelText: '关闭',
confirmText: '复制地址',
success: (res) => {
if (res.confirm) {
uni.setClipboardData({
data: import.meta.env.VITE_SERVER_BASEURL || '',
showToast: false,
success: () => {
showToast('复制成功')
},
})
}
},
})
return
}
submitLoading.value = true
uni.showLoading({ title: '正在保存...' })
try {
await submitVote(name.value, submitForm.value, vote.value.spec.canAnonymously)
showToast('提交成功')
voteCacheUtil.set(name.value, {
selected: [...submitForm.value.voteData],
data: vote.value,
})
setTimeout(() => {
uni.startPullDownRefresh()
submitLoading.value = false
}, 1500)
}
catch (err) {
console.error(err)
showToast('提交失败,请重试')
submitLoading.value = false
}
finally {
uni.hideLoading()
}
}
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
name.value = options?.name || ''
// #ifndef H5
const systemInfo = uni.getSystemInfoSync()
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom ? systemInfo.safeAreaInsets.bottom + 12 : 24
// #endif
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
onShareAppMessage(() => ({
path: `/pages-blog/vote-detail/vote-detail?name=${name.value}`,
title: vote.value?.spec?.title || '来投个票吧',
imageUrl: '',
}))
onShareTimeline(() => ({
title: vote.value?.spec?.title || '来投个票吧',
query: name.value ? `name=${name.value}` : '',
imageUrl: '',
}))
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col py-6 pb-[160rpx]" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<view v-if="!vote" class="empty h-[60vh] flex items-center justify-center">
<wd-empty description="未查询到数据" />
</view>
<block v-else>
<!-- 投票信息 -->
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票信息
</view>
<view class="vote-card-body flex flex-col gap-3 rounded-xl bg-[#f3f4f6] p-6 text-[28rpx] text-[#3f3f3f]">
<view class="info-row">
<text>投票类型</text>
<text class="tag">{{ vote.spec?._uh_type }}</text>
</view>
<view class="info-row">
<text>投票状态</text>
<text class="tag" :style="{ color: vote.spec?._uh_state?.color }">{{ vote.spec?._uh_state?.state }}</text>
</view>
<view class="info-row">
<text>投票方式</text>
<text class="tag" :class="vote.spec?.canAnonymously ? 'text-[#03a9f4]' : 'text-[#f44336]'">
{{ vote.spec?.canAnonymously ? '匿名' : '不匿名' }}
</text>
</view>
<view class="info-row">
<text>开始时间{{ formatTime(vote.spec?.startDate) }}</text>
</view>
<view class="info-row">
<text v-if="vote.spec?.timeLimit === 'permanent'">结束时间永久有效</text>
<text v-else>结束时间{{ formatTime(vote.spec?.endDate) }}</text>
</view>
</view>
</view>
<!-- 投票内容 -->
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票内容
</view>
<view class="sub-content mb-3 pt-3 text-[30rpx] text-[#2b2f33] font-bold">
{{ vote.spec?.title }}
</view>
<view v-if="vote.spec?.remark" class="sub-remark mb-9 pt-3 text-[28rpx] text-[#3f3f3f]">
{{ vote.spec.remark }}
</view>
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票选项
<text v-if="vote.spec?.type === 'multiple'" class="sub-title-count text-[24rpx] font-normal">最多选择 {{ vote.spec?.maxVotes }} </text>
</view>
<view class="options mt-6 flex flex-col gap-4">
<template v-if="isVoted || isEnded">
<view
v-for="(option, optionIndex) in vote.spec?.options"
:key="optionIndex"
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
:class="option.checked ? 'bg-[#03a9f4]/35 text-white' : 'bg-[#e5e5e5]/75'"
:style="{ '--percent': `${option._uh_percent}%` }"
>
<view class="is-voted-item-content relative z-2 box-border min-h-[72rpx] px-6 py-3">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
{{ option.title }}
</view>
<view class="shrink-0">
{{ option._uh_percent }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view
v-for="(option, optionIndex) in vote.spec?.options"
:key="optionIndex"
class="vote-select-option box-border rounded-xl bg-[#f3f4f6] px-6 py-5 text-[24rpx]"
:class="option.checked ? 'border-2 border-[#03a9f4] bg-[#03a9f4]/15 text-[#03a9f4]' : ''"
@click="vote.spec?.type === 'multiple' ? handleSelectCheckboxOption(option) : handleSelectSingleOption(option)"
>
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}` : '' }}{{ option.title }}
</view>
</template>
</view>
</view>
<!-- 投票统计 -->
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票统计
</view>
<view class="stat-text mt-3 text-[26rpx] text-[#606266]">
{{ vote.stats?.voteCount || 0 }} 人已参与
</view>
</view>
<!-- 提交按钮 -->
<view class="vote-submit fixed bottom-0 left-0 z-99 box-border w-screen border-t-2 border-[#eee] bg-white/98 px-9 py-6 shadow-sm" :style="{ paddingBottom: `${safeAreaBottom}rpx` }">
<wd-button v-if="isVoted" disabled block>
您已参与投票
</wd-button>
<wd-button v-else-if="vote.spec?._uh_state?.state === '未开始'" plain block type="warning" @click="handleSubmitTip('投票未开始')">
投票未开始
</wd-button>
<wd-button v-else-if="vote.spec?._uh_state?.state === '已结束'" plain block type="danger" @click="handleSubmitTip('投票已结束')">
投票已结束
</wd-button>
<wd-button v-else-if="!vote.spec?.canAnonymously" plain block type="danger" @click="handleSubmit()">
不支持匿名投票
</wd-button>
<wd-button v-else-if="submitForm.voteData.length === 0" plain block @click="handleSubmitTip('请选择选项')">
提交投票请选择选项
</wd-button>
<wd-button v-else block type="primary" :loading="submitLoading" :disabled="submitLoading" @click="handleSubmit()">
提交投票
</wd-button>
</view>
</block>
</block>
</view>
</template>
<style scoped lang="scss">
.vote-card {
.sub-title {
&::before {
content: '';
width: 8rpx;
height: 28rpx;
position: absolute;
left: 0;
top: 6rpx;
background: #03a9f4;
border-radius: 6rpx;
}
}
.is-voted-item {
&::before {
content: '';
width: var(--percent);
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: #d0d0d0;
z-index: 0;
border-radius: 6rpx;
}
}
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts" setup>
/**
* 投票列表页(源自旧项目 pagesA/votes,新建复刻)
* 展示投票列表,每个投票项用 uh-vote-card 渲染
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getVoteList } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import type { IVoteItem } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '投票中心',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const calcAuditModeEnabled = computed(() => !!appConfigStore.configs.auditConfig?.auditModeEnabled)
/** 依赖插件(plugin-vote) */
const uniHaloPluginId = 'plugin-vote'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const dataList = ref<IVoteItem[]>([])
const hasNext = ref(false)
const queryParams = ref({ page: 1, size: 10 })
const isLoadMore = ref(false)
const loadMoreText = ref('加载中...')
async function handleGetData() {
if (calcAuditModeEnabled.value) {
loading.value = 'success'
loadMoreText.value = '呜呜,没有更多数据啦~'
uni.stopPullDownRefresh()
return
}
uni.showLoading({ mask: true, title: '加载中...' })
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = '加载中...'
try {
const res = await getVoteList({ ...queryParams.value })
loading.value = 'success'
hasNext.value = (res.data as unknown as { hasNext?: boolean }).hasNext || false
dataList.value = isLoadMore.value
? dataList.value.concat(res.data as IVoteItem[])
: (res.data as IVoteItem[])
loadMoreText.value = hasNext.value ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
function handleOnVoteSuccess() {
uni.showToast({ icon: 'none', title: '投票成功!' })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
handleGetData()
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用投票功能哦请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else>
<view v-if="loading !== 'success'" class="loading-wrap p-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<view v-else class="content flex flex-col gap-6 p-3">
<view v-if="dataList.length === 0" class="min-h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="博主还未发布投票~" />
</view>
<block v-else>
<uh-vote-card
v-for="vote in dataList"
:key="vote.name"
:vote-name="vote.name"
@on-vote-success="handleOnVoteSuccess"
/>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts" setup>
/**
* 网站浏览页(源自旧项目 pagesC/website,新建复刻)
* 内嵌 web-view 展示外部链接
*/
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
definePage({
style: {
navigationBarTitleText: '加载中...',
},
})
const webUrl = ref('')
onLoad((options) => {
try {
const data = JSON.parse(options?.data || '{}')
const { title, url } = data
webUrl.value = decodeURIComponent(url || '')
if (title) {
uni.setNavigationBarTitle({ title })
}
}
catch (err) {
console.error('解析网站参数失败', err)
}
})
</script>
<template>
<view class="app-page w-screen">
<web-view v-if="webUrl" :src="webUrl" />
<view v-else class="text-grey h-[60vh] flex items-center justify-center">
<wd-empty description="链接地址为空" />
</view>
</view>
</template>
<style scoped>
.app-page {
width: 100vw;
}
</style>