mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-12 16:40:40 +08:00
feat: 新增公告模块、数据加载hook与多项体验优化
- 新增公告全流程功能:首页公告轮播、公告列表页、公告详情页 - 新增统一数据加载状态hook useDataLoading与配套测试 - 重构配置加载逻辑,新增统一bootstrap静态配置拉取 - 新增维护模式、恋爱模块支持与验证码防刷功能 - 优化投票卡片、联系页、关于页UI样式 - 新增测试页面与全局玻璃卡片样式 - 修复markdown容器内边距与首页生命周期逻辑
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
scene: git_message
|
||||
---
|
||||
使用中文编写提交信息
|
||||
+45
-46
@@ -1,57 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
|
||||
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
||||
import { navigateToInterceptor } from '@/router/interceptor'
|
||||
import { tabbarStore } from '@/tabbar/store'
|
||||
import { permission } from '@/router/permission'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
|
||||
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
||||
import { navigateToInterceptor } from '@/router/interceptor'
|
||||
import { tabbarStore } from '@/tabbar/store'
|
||||
import { permission } from '@/router/permission'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
|
||||
const { proxy } = (getCurrentInstance() || {}) as any
|
||||
const router = proxy?.$router
|
||||
const { proxy } = (getCurrentInstance() || {}) as any
|
||||
const router = proxy?.$router
|
||||
|
||||
router && permission.install(router)
|
||||
router && permission.install(router)
|
||||
|
||||
onLaunch((options) => {
|
||||
|
||||
// 初始化获取配置
|
||||
useAppConfigStore()
|
||||
|
||||
console.log('App.vue onLaunch', options)
|
||||
})
|
||||
onShow((options) => {
|
||||
console.log('App.vue onShow', options)
|
||||
// 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
|
||||
// https://github.com/unibest-tech/unibest/issues/192
|
||||
if (options?.path) {
|
||||
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
|
||||
}
|
||||
else {
|
||||
navigateToInterceptor.invoke({ url: '/' })
|
||||
}
|
||||
})
|
||||
onHide(() => {
|
||||
console.log('App Hide')
|
||||
})
|
||||
onLaunch((options) => {
|
||||
// 初始化获取配置
|
||||
useAppConfigStore()
|
||||
|
||||
// #ifdef H5
|
||||
function syncTabbarWhenPageVisible() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
tabbarStore.syncCurIdxByCurrentPageAsync()
|
||||
}
|
||||
}
|
||||
console.log('App.vue onLaunch', options)
|
||||
})
|
||||
onShow((options) => {
|
||||
console.log('App.vue onShow', options)
|
||||
// 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
|
||||
// https://github.com/unibest-tech/unibest/issues/192
|
||||
if (options?.path) {
|
||||
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
|
||||
}
|
||||
else {
|
||||
navigateToInterceptor.invoke({ url: '/' })
|
||||
}
|
||||
})
|
||||
onHide(() => {
|
||||
console.log('App Hide')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', syncTabbarWhenPageVisible)
|
||||
window.addEventListener('pageshow', syncTabbarWhenPageVisible)
|
||||
})
|
||||
// #ifdef H5
|
||||
function syncTabbarWhenPageVisible() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
tabbarStore.syncCurIdxByCurrentPageAsync()
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', syncTabbarWhenPageVisible)
|
||||
window.removeEventListener('pageshow', syncTabbarWhenPageVisible)
|
||||
})
|
||||
// #endif
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', syncTabbarWhenPageVisible)
|
||||
window.addEventListener('pageshow', syncTabbarWhenPageVisible)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', syncTabbarWhenPageVisible)
|
||||
window.removeEventListener('pageshow', syncTabbarWhenPageVisible)
|
||||
})
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -124,6 +124,29 @@ export interface IAppConfig {
|
||||
articleCardType?: string
|
||||
avatarRadius?: boolean
|
||||
}
|
||||
/**
|
||||
* 维护模式(additive,2026-09-04 插件端 GeneralConfig.spec.maintenance 经 getConfigs
|
||||
* 下发;仅 scheduled/active 时存在,键缺失=未维护或已到点自动结束)
|
||||
*/
|
||||
maintenance?: IPublicMaintenance
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* getConfigs 顶层 maintenance 键(插件端输出;status 判定权威在服务端,客户端仅据此
|
||||
* 展示维护页与倒计时,设计见插件 .docs/maintenance-config-design.md)
|
||||
*/
|
||||
export interface IPublicMaintenance {
|
||||
/** scheduled 维护预告(倒计时至 startTime)/ active 维护中(倒计时至 endTime) */
|
||||
status: 'scheduled' | 'active'
|
||||
/** 维护页标题 */
|
||||
title?: string
|
||||
/** 维护说明(富文本 HTML,mp-html 渲染) */
|
||||
description?: string
|
||||
/** 维护开始时间(RFC3339 UTC 字符串) */
|
||||
startTime?: string
|
||||
/** 预计恢复时间(RFC3339 UTC 字符串) */
|
||||
endTime?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -269,6 +292,54 @@ export interface ICommentCaptcha {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/* ---------- 通知公告(plugin-uni-halo notice,2026-09-03 客户端接入) ---------- */
|
||||
|
||||
/** 公告公开列表项(脱敏,不含 content;内嵌类型信息 typeDisplayName/typeColor) */
|
||||
export interface INoticeListVo {
|
||||
name?: string
|
||||
title?: string
|
||||
summary?: string
|
||||
cover?: string
|
||||
link?: string
|
||||
typeName?: string
|
||||
typeDisplayName?: string
|
||||
typeColor?: string
|
||||
priority?: number
|
||||
/** 发布时间(spec.publishTime,ISO-8601) */
|
||||
publishTime?: string
|
||||
}
|
||||
|
||||
/** 公告公开分页列表响应(ListResult 形态) */
|
||||
export interface INoticeListRes {
|
||||
page?: number
|
||||
size?: number
|
||||
total?: number
|
||||
items: INoticeListVo[]
|
||||
}
|
||||
|
||||
/** 公告详情 spec(完整 Notice + 内嵌 typeDisplayName/typeColor) */
|
||||
export interface INoticeDetailSpec {
|
||||
title?: string
|
||||
/** 富文本 HTML(仅详情返回) */
|
||||
content?: string
|
||||
summary?: string
|
||||
cover?: string
|
||||
link?: string
|
||||
typeName?: string
|
||||
status?: string
|
||||
priority?: number
|
||||
publishTime?: string
|
||||
typeDisplayName?: string
|
||||
typeColor?: string
|
||||
}
|
||||
|
||||
/** 公告详情(完整 extension 对象: metadata + spec) */
|
||||
export interface INoticeDetail {
|
||||
metadata?: { name?: string, [key: string]: unknown }
|
||||
spec?: INoticeDetailSpec
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/* ---------- 恋爱模块(plugin-uni-halo love) ---------- */
|
||||
|
||||
export interface ILoveConfig {
|
||||
|
||||
+72
-4
@@ -34,6 +34,9 @@ import type {
|
||||
IMiniProgramLinkGroupedRes,
|
||||
IMiniProgramLinkGroupOption,
|
||||
IMiniProgramLinkSubmissionForm,
|
||||
INoticeDetail,
|
||||
INoticeListRes,
|
||||
INoticeListVo,
|
||||
IQRCodeInfo,
|
||||
IRestrictReadCheckReq,
|
||||
IRestrictReadCheckRes,
|
||||
@@ -109,7 +112,7 @@ export function getCommentWidgetConfig() {
|
||||
* 检查更新(uhalo-upgrade 插件对接,插件本体引入后业务侧入口)
|
||||
*/
|
||||
export function checkVersion(baseUrl: string) {
|
||||
return http.Get<IResponse<IUpdateCheckRes>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/checkVersion', {
|
||||
return http.Get<IResponse<IUpdateCheckRes>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/upgrade/checkVersion', {
|
||||
params: { baseUrl },
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
@@ -124,6 +127,68 @@ export function getQRCodeInfo(key: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/* ==================== 防刷验证码(plugin-uni-halo,2026-09-03 客户端接入) ==================== */
|
||||
|
||||
/** 防刷验证码(服务端 captcha/generate 返回;受保护写接口 403 时响应体亦附带新验证码) */
|
||||
export interface IPluginCaptcha {
|
||||
id: string
|
||||
imageBase64: string
|
||||
}
|
||||
|
||||
/** 受保护写接口携带的验证码 query(未触发时省略,触发后每次提交都需携带,一次性) */
|
||||
export interface ICaptchaQuery {
|
||||
captchaId?: string
|
||||
captchaCode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取防刷验证码(公开;适用 POST /mini-program-links/submissions 与
|
||||
* /love-albums/{name}/unlock;站点关闭验证码或该功能不在生效范围时无需携带)
|
||||
*/
|
||||
export function getPluginCaptcha() {
|
||||
return http.Get<IResponse<IPluginCaptcha>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/captcha/generate', {
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
}
|
||||
|
||||
/** 仅当携带有效验证码时拼接 query 参数 */
|
||||
export function buildCaptchaQuery(captcha?: ICaptchaQuery | null): ICaptchaQuery | undefined {
|
||||
if (!captcha || !captcha.captchaId || !captcha.captchaCode)
|
||||
return undefined
|
||||
return { captchaId: captcha.captchaId, captchaCode: captcha.captchaCode }
|
||||
}
|
||||
|
||||
/* ==================== 通知公告(plugin-uni-halo,2026-09-03 客户端接入) ==================== */
|
||||
|
||||
/**
|
||||
* 公告分页列表(公开,仅已发布;脱敏不含 content,内嵌类型信息)。
|
||||
* 注意:公开接口当前仅支持分页,类型筛选/排序由页面本地聚合处理(公告量小)。
|
||||
*/
|
||||
export function getNotices(params: { page?: number, size?: number }) {
|
||||
return http.Get<IResponse<INoticeListRes>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/notices', {
|
||||
params,
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新一条已发布公告(公开;无数据返回 null,供首页/我的弹窗)
|
||||
*/
|
||||
export function getNoticeLatest() {
|
||||
return http.Get<IResponse<INoticeListVo | null>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/notices/latest', {
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公告详情(公开;含 content 富文本 HTML 与内嵌类型信息;不存在/删除中返回 404)
|
||||
*/
|
||||
export function getNoticeDetail(name: string) {
|
||||
return http.Get<IResponse<INoticeDetail>>(`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/notices/${name}`, {
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
}
|
||||
|
||||
/* ==================== 恋爱模块 ==================== */
|
||||
|
||||
/**
|
||||
@@ -158,13 +223,14 @@ export function getLoveAlbumByName(name: string, params: ILoveAlbumListReq) {
|
||||
/**
|
||||
* 密码解锁相册
|
||||
*/
|
||||
export function unlockAlbum(name: string, password: string) {
|
||||
export function unlockAlbum(name: string, password: string, captcha?: ICaptchaQuery | null) {
|
||||
return http.Post<IResponse<{ token: string, photos?: unknown[] }>>(
|
||||
`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-albums/${name}/unlock`,
|
||||
{
|
||||
password,
|
||||
},
|
||||
{
|
||||
params: buildCaptchaQuery(captcha),
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
},
|
||||
)
|
||||
@@ -233,15 +299,17 @@ export function getMiniProgramLinkDetail(name: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交小程序链接申请(公开接口,落库为待审核;受 linkConfig.submissionEnabled 开关控制)
|
||||
* 提交小程序链接申请(公开接口,落库为待审核;受 linkConfig.submissionEnabled 开关
|
||||
* 与防刷验证码 captchaConfig 控制)
|
||||
*/
|
||||
export function submitMiniProgramLinkApplication(data: IMiniProgramLinkSubmissionForm) {
|
||||
export function submitMiniProgramLinkApplication(data: IMiniProgramLinkSubmissionForm, captcha?: ICaptchaQuery | null) {
|
||||
return http.Post<IResponse<unknown>>(
|
||||
'/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/mini-program-links/submissions',
|
||||
{
|
||||
spec: data,
|
||||
},
|
||||
{
|
||||
params: buildCaptchaQuery(captcha),
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 相册密码解锁弹窗(源自旧项目 components/album-unlock-modal,新建复刻)
|
||||
* 输入密码解锁加密相册
|
||||
* 输入密码解锁加密相册;2026-09-03 接入插件防刷验证码:
|
||||
* 首次提交若服务端要求验证码(403+附新码)则展示验证码行,携带后重试(验证码一次性)
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { unlockAlbum } from '@/api/uni-halo'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getPluginCaptcha, unlockAlbum } from '@/api/uni-halo'
|
||||
import type { ICaptchaQuery, IPluginCaptcha } from '@/api/uni-halo'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
show: boolean
|
||||
@@ -24,12 +26,62 @@ const isShow = ref(false)
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// 防刷验证码(服务端 403 附新码 / 主动刷新)
|
||||
const captchaImage = ref('')
|
||||
const captchaId = ref('')
|
||||
const captchaCode = ref('')
|
||||
const captchaLoading = ref(false)
|
||||
|
||||
const captchaSrc = computed(() => {
|
||||
if (!captchaImage.value)
|
||||
return ''
|
||||
return captchaImage.value.startsWith('data:')
|
||||
? captchaImage.value
|
||||
: `data:image/png;base64,${captchaImage.value}`
|
||||
})
|
||||
|
||||
function resetCaptcha() {
|
||||
captchaImage.value = ''
|
||||
captchaId.value = ''
|
||||
captchaCode.value = ''
|
||||
}
|
||||
|
||||
/** 用服务端返回的验证码(403 响应体附新码)填充展示 */
|
||||
function applyCaptcha(captcha: IPluginCaptcha) {
|
||||
captchaImage.value = captcha.imageBase64
|
||||
captchaId.value = captcha.id
|
||||
captchaCode.value = ''
|
||||
}
|
||||
|
||||
/** 点击验证码图刷新 */
|
||||
async function handleRefreshCaptcha() {
|
||||
if (captchaLoading.value)
|
||||
return
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
const res = await getPluginCaptcha()
|
||||
if (res.data)
|
||||
applyCaptcha(res.data)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('获取验证码失败', e)
|
||||
}
|
||||
finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
isShow.value = val
|
||||
if (val) {
|
||||
password.value = ''
|
||||
resetCaptcha()
|
||||
}
|
||||
})
|
||||
|
||||
function handleOnCancel() {
|
||||
password.value = ''
|
||||
resetCaptcha()
|
||||
isShow.value = false
|
||||
emit('update:show', false)
|
||||
}
|
||||
@@ -42,9 +94,13 @@ async function handleOnConfirm() {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await unlockAlbum(props.albumKey, password.value)
|
||||
const captchaQuery: ICaptchaQuery | undefined = captchaImage.value
|
||||
? { captchaId: captchaId.value, captchaCode: captchaCode.value }
|
||||
: undefined
|
||||
const res = await unlockAlbum(props.albumKey, password.value, captchaQuery)
|
||||
if (res.data && (res.data as { token?: string }).token) {
|
||||
password.value = ''
|
||||
resetCaptcha()
|
||||
isShow.value = false
|
||||
emit('update:show', false)
|
||||
emit('success', {
|
||||
@@ -57,8 +113,18 @@ async function handleOnConfirm() {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('解锁失败', e)
|
||||
password.value = ''
|
||||
uni.showToast({ title: '密码错误,请重试', icon: 'none' })
|
||||
const err = e as { code?: number, data?: { message?: string, captcha?: IPluginCaptcha } }
|
||||
if (err.code === 403 && err.data?.captcha) {
|
||||
// 需要/校验失败:服务端附新验证码(一次性,旧码已作废),展示并要求重试
|
||||
applyCaptcha(err.data.captcha)
|
||||
uni.showToast({ title: '请完成验证码后重新解锁', icon: 'none' })
|
||||
}
|
||||
else {
|
||||
// 密码错误等业务失败:清空密码并复位验证码(一次性,需重新获取)
|
||||
password.value = ''
|
||||
resetCaptcha()
|
||||
uni.showToast({ title: '密码错误,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -96,6 +162,26 @@ async function handleOnConfirm() {
|
||||
clearable
|
||||
class="password-input mt-9"
|
||||
/>
|
||||
|
||||
<!-- 防刷验证码(首次提交 403 后展示;点击图片可刷新) -->
|
||||
<view v-if="captchaSrc" class="captcha-box mt-5 flex items-center justify-center gap-4">
|
||||
<image
|
||||
:src="captchaSrc"
|
||||
class="captcha-img h-[76rpx] w-[200rpx] rounded-lg"
|
||||
mode="widthFix"
|
||||
@click="handleRefreshCaptcha"
|
||||
/>
|
||||
<wd-input
|
||||
v-model="captchaCode"
|
||||
placeholder="验证码"
|
||||
align="center"
|
||||
clearable
|
||||
class="captcha-input w-[240rpx]"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="captchaSrc" class="captcha-tip mt-2 text-center text-[22rpx] text-[#aaa]">
|
||||
点击图片刷新验证码
|
||||
</view>
|
||||
</view>
|
||||
</wd-dialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useAttrs } from 'vue';
|
||||
|
||||
interface IProps {
|
||||
customClass ?: string | Array<string>;
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>();
|
||||
const attrs = useAttrs();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-bind="attrs" class="bg-primary uh-shadow-xs text-black text-sm px-4 py-1.5 rounded-lg" :class="props.customClass">
|
||||
<slot></slot>
|
||||
</view>
|
||||
</template>
|
||||
@@ -1,41 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { DataLoadingStatusEnum } from '@/hooks/useDataLoadingStatus'
|
||||
import type { DataLoadingStatus } from '@/hooks/useDataLoading'
|
||||
|
||||
interface IProps {
|
||||
loadingStatus?: DataLoadingStatusEnum
|
||||
/** 加载状态(取值同 useDataLoading 返回的 status) */
|
||||
loadingStatus?: DataLoadingStatus
|
||||
/** 占位区最小高度 */
|
||||
minHeight?: string
|
||||
loadingText?: string
|
||||
errorText?: string
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
loadingStatus: DataLoadingStatusEnum.Loading,
|
||||
loadingStatus: 'loading',
|
||||
minHeight: '60vh',
|
||||
loadingText: '稍等,正在努力加载中哦...',
|
||||
errorText: '哎呀,加载失败了呢~',
|
||||
emptyText: '啊偶,暂时没有数据呢~',
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
function handleRefresh() {
|
||||
emit('refresh')
|
||||
}
|
||||
const emit = defineEmits<{ (e: 'refresh'): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="min-h-screen w-full flex items-center justify-center">
|
||||
<view
|
||||
class="w-full flex flex-col items-center justify-center gap-y-4 text-sm"
|
||||
:style="{ minHeight: props.minHeight }"
|
||||
>
|
||||
<!-- 加载中 -->
|
||||
<wd-loading v-if="props.loadingStatus === DataLoadingStatusEnum.Loading" :size="60">
|
||||
<view>加载中...</view>
|
||||
</wd-loading>
|
||||
<template v-if="props.loadingStatus === 'loading'">
|
||||
<!-- 表情上下漂浮动画 -->
|
||||
<text class="bob-icon">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-happy-1" size="100rpx" />
|
||||
</text>
|
||||
<view class="text-primary">
|
||||
{{ props.loadingText }}
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 加载错误 -->
|
||||
<wd-empty v-else-if="props.loadingStatus === DataLoadingStatusEnum.Error" :icon-size="60" icon="no-result">
|
||||
<view>加载失败</view>
|
||||
<wd-button @click="handleRefresh">
|
||||
<!-- 加载错误(可重试) -->
|
||||
<template v-else-if="props.loadingStatus === 'error'">
|
||||
<!-- 表情上下漂浮动画 -->
|
||||
<text class="bob-icon">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-injury" size="100rpx" />
|
||||
</text>
|
||||
<view class="text-red-400">
|
||||
{{ props.errorText }}
|
||||
</view>
|
||||
<uh-button @click="emit('refresh')">
|
||||
刷新试试
|
||||
</wd-button>
|
||||
</wd-empty>
|
||||
</uh-button>
|
||||
</template>
|
||||
|
||||
<!-- 加载成功 -->
|
||||
<wd-empty v-else-if="props.loadingStatus === DataLoadingStatusEnum.Empty" :icon-size="60" icon="success">
|
||||
<view>无数据</view>
|
||||
<wd-button @click="handleRefresh">
|
||||
刷新试试
|
||||
</wd-button>
|
||||
</wd-empty>
|
||||
<!-- 无数据 -->
|
||||
<template v-else>
|
||||
<!-- 表情上下漂浮动画 -->
|
||||
<text class="bob-icon">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-confused" size="120rpx" />
|
||||
</text>
|
||||
<view class="text-gray-900">
|
||||
{{ props.emptyText }}
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 状态表情上下漂浮动画:@keyframes 无法用 UnoCSS / wot-ui 原子类表达,保留 scoped 样式 */
|
||||
@keyframes bob-icon-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-18rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.bob-icon {
|
||||
display: inline-block;
|
||||
animation: bob-icon-float 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 首页公告滚动条(plugin-uni-halo 通知公告,2026-09-03 客户端新增)
|
||||
* 展示最新/置顶公告(公开 GET /notices 服务端默认 priority desc + publishTime desc),
|
||||
* 取前 6 条标题垂直循环轮播:点击当前标题跳公告详情,右侧「更多」跳公告列表页。
|
||||
* 无公告(或加载失败)时整条不渲染,不占首页空间。
|
||||
* UIUX 见 .docs/notice-module-client-design.md
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getNotices } from '@/api/uni-halo'
|
||||
import type { INoticeListVo } from '@/api/types/uni-halo'
|
||||
|
||||
/** 轮播展示条数上限(垂直循环) */
|
||||
const MAX_SHOW = 6
|
||||
|
||||
const list = ref<INoticeListVo[]>([])
|
||||
const showList = computed(() => list.value.slice(0, MAX_SHOW))
|
||||
|
||||
async function fetchNotices() {
|
||||
try {
|
||||
const res = await getNotices({ page: 1, size: MAX_SHOW })
|
||||
list.value = res.data?.items || []
|
||||
}
|
||||
catch (err) {
|
||||
console.error('首页公告获取失败', err)
|
||||
list.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击单条公告标题 → 详情页 */
|
||||
function handleTap(item: INoticeListVo) {
|
||||
if (!item.name)
|
||||
return
|
||||
uni.navigateTo({ url: `/pages-blog/notice/detail?name=${item.name}` })
|
||||
}
|
||||
|
||||
/** 点击「公告/更多」→ 公告列表页 */
|
||||
function handleGoList() {
|
||||
uni.navigateTo({ url: '/pages-blog/notice/notice' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchNotices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="showList.length > 0"
|
||||
class="mx-3 mb-2 flex items-center rounded-xl bg-white px-3 py-1.5 shadow-sm"
|
||||
>
|
||||
<!-- 左侧公告入口 -->
|
||||
<view class="flex shrink-0 items-center gap-1 py-2 pr-3" @click="handleGoList">
|
||||
<text class="text-[28rpx]">
|
||||
📢
|
||||
</text>
|
||||
<text class="text-[24rpx] font-bold text-[#f83856]">
|
||||
公告
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 标题轮播(仅一条时静态展示) -->
|
||||
<view class="h-[60rpx] min-w-0 flex-1 overflow-hidden">
|
||||
<swiper
|
||||
v-if="showList.length > 1"
|
||||
class="h-full w-full"
|
||||
vertical
|
||||
circular
|
||||
autoplay
|
||||
:interval="3500"
|
||||
:duration="400"
|
||||
>
|
||||
<swiper-item
|
||||
v-for="(item, index) in showList"
|
||||
:key="item.name || index"
|
||||
class="h-full w-full"
|
||||
>
|
||||
<view
|
||||
class="flex h-full w-full items-center truncate text-[24rpx] text-[#555]"
|
||||
@click="handleTap(item)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view
|
||||
v-else
|
||||
class="flex h-full w-full items-center truncate text-[24rpx] text-[#555]"
|
||||
@click="handleTap(showList[0])"
|
||||
>
|
||||
{{ showList[0]?.title }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 右侧更多入口 -->
|
||||
<view class="flex shrink-0 items-center gap-0.5 py-2 pl-2" @click="handleGoList">
|
||||
<text class="text-[22rpx] text-[#bbb]">
|
||||
更多
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#bbb" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 布局由 UnoCSS 原子类实现 */
|
||||
</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 小程序链接申请弹窗(源自 mini-program-links-design 申请入口规划)
|
||||
* 公开提交到 plugin-uni-halo POST /submissions,落库为待审核(受 linkConfig.submissionEnabled 开关控制)
|
||||
* 公开提交到 plugin-uni-halo POST /submissions,落库为待审核(受 linkConfig.submissionEnabled
|
||||
* 开关控制);2026-09-03 接入插件防刷验证码(403+附新码 → 展示验证码行携带重试,一次性)
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { submitMiniProgramLinkApplication } from '@/api/uni-halo'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getPluginCaptcha, submitMiniProgramLinkApplication } from '@/api/uni-halo'
|
||||
import type { ICaptchaQuery, IPluginCaptcha } from '@/api/uni-halo'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
show?: boolean
|
||||
@@ -44,6 +46,49 @@ const form = ref<IApplyForm>({
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// 防刷验证码(服务端 403 附新码 / 点击图片刷新)
|
||||
const captchaImage = ref('')
|
||||
const captchaId = ref('')
|
||||
const captchaCode = ref('')
|
||||
const captchaLoading = ref(false)
|
||||
|
||||
const captchaSrc = computed(() => {
|
||||
if (!captchaImage.value)
|
||||
return ''
|
||||
return captchaImage.value.startsWith('data:')
|
||||
? captchaImage.value
|
||||
: `data:image/png;base64,${captchaImage.value}`
|
||||
})
|
||||
|
||||
function resetCaptcha() {
|
||||
captchaImage.value = ''
|
||||
captchaId.value = ''
|
||||
captchaCode.value = ''
|
||||
}
|
||||
|
||||
function applyCaptcha(captcha: IPluginCaptcha) {
|
||||
captchaImage.value = captcha.imageBase64
|
||||
captchaId.value = captcha.id
|
||||
captchaCode.value = ''
|
||||
}
|
||||
|
||||
async function handleRefreshCaptcha() {
|
||||
if (captchaLoading.value)
|
||||
return
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
const res = await getPluginCaptcha()
|
||||
if (res.data)
|
||||
applyCaptcha(res.data)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('获取验证码失败', e)
|
||||
}
|
||||
finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetForm() {
|
||||
form.value = {
|
||||
displayName: '',
|
||||
@@ -56,6 +101,7 @@ function handleResetForm() {
|
||||
applyRemark: '',
|
||||
email: '',
|
||||
}
|
||||
resetCaptcha()
|
||||
}
|
||||
|
||||
function checkIsUrl(url: string): boolean {
|
||||
@@ -107,6 +153,9 @@ async function handleHandle() {
|
||||
submitting.value = true
|
||||
uni.showLoading({ title: '正在提交...' })
|
||||
try {
|
||||
const captchaQuery: ICaptchaQuery | undefined = captchaImage.value
|
||||
? { captchaId: captchaId.value, captchaCode: captchaCode.value }
|
||||
: undefined
|
||||
await submitMiniProgramLinkApplication({
|
||||
displayName: form.value.displayName.trim(),
|
||||
miniProgramCode: form.value.miniProgramCode.trim(),
|
||||
@@ -117,14 +166,22 @@ async function handleHandle() {
|
||||
description: form.value.description.trim() || undefined,
|
||||
applyRemark: form.value.applyRemark.trim() || undefined,
|
||||
email: form.value.email.trim() || undefined,
|
||||
})
|
||||
}, captchaQuery)
|
||||
uni.showToast({ icon: 'none', title: '申请提交成功,等待审核!' })
|
||||
handleClose(true)
|
||||
handleResetForm()
|
||||
}
|
||||
catch (err) {
|
||||
console.error('小程序链接申请提交失败', err)
|
||||
uni.showToast({ icon: 'none', title: '提交失败,请稍后重试!' })
|
||||
const e = err as { code?: number, data?: { message?: string, captcha?: IPluginCaptcha } }
|
||||
if (e.code === 403 && e.data?.captcha) {
|
||||
// 需要/校验失败:服务端附新验证码(一次性),展示并要求重试
|
||||
applyCaptcha(e.data.captcha)
|
||||
uni.showToast({ icon: 'none', title: '请完成验证码后重新提交' })
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: '提交失败,请稍后重试!' })
|
||||
}
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
@@ -207,6 +264,22 @@ watch(() => props.show, (newVal) => {
|
||||
<input v-model="form.email" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="审核结果通知(选填)">
|
||||
</view>
|
||||
|
||||
<!-- 防刷验证码(提交 403 后展示;点击图片可刷新) -->
|
||||
<view v-if="captchaSrc" class="captcha-box mb-5 flex items-center">
|
||||
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">验证码 *</text>
|
||||
<image
|
||||
:src="captchaSrc"
|
||||
class="captcha-img h-[76rpx] w-[200rpx] shrink-0 rounded-lg"
|
||||
mode="widthFix"
|
||||
@click="handleRefreshCaptcha"
|
||||
/>
|
||||
<input
|
||||
v-model="captchaCode"
|
||||
class="input ml-3 h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]"
|
||||
placeholder="输入图中字符"
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="submit-btn my-6">
|
||||
<wd-button type="primary" block size="medium" :loading="submitting" @click="handleHandle">
|
||||
提交申请
|
||||
|
||||
@@ -1,77 +1,144 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 通知弹窗(源自旧项目 components/notify-dialog,新建复刻)
|
||||
* wd-popup + mp-html 渲染 markdown 内容,可选跳转外链
|
||||
* 通知/最新公告弹窗(源自旧项目 components/notify-dialog,2026-09-03 改造为
|
||||
* plugin-uni-halo 最新公告自治宿主):挂载后自动拉取 GET /notices/latest,
|
||||
* 「今日已看」命中(键 notice_latest_{name}_{yyyyMMdd})则不打扰;
|
||||
* 「不再提醒/查看全文」写今日键;查看全文跳公告详情页。
|
||||
* 放置:页面模板中直接 <uh-notify-dialog /> 即可(easycom)。
|
||||
* UIUX 见 .docs/notice-module-client-design.md
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { checkIsUrl } from '@/utils/url'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getNoticeLatest } from '@/api/uni-halo'
|
||||
import { getCache, setCache } from '@/utils/storage'
|
||||
import type { INoticeListVo } from '@/api/types/uni-halo'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
title: string
|
||||
content: string
|
||||
url: string
|
||||
}>()
|
||||
const HIDDEN_KEY_PREFIX = 'notice_latest_hidden'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'on-change', show: boolean): void
|
||||
}>()
|
||||
const isShow = ref(false)
|
||||
const notice = ref<INoticeListVo | null>(null)
|
||||
const checking = ref(false)
|
||||
|
||||
const isShow = ref(props.show)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
isShow.value = val
|
||||
})
|
||||
|
||||
function handleOnChange(e: unknown) {
|
||||
isShow.value = false
|
||||
emit('on-change', Boolean(e))
|
||||
function todayKey(name: string): string {
|
||||
const date = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${HIDDEN_KEY_PREFIX}_${name}_${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
function handleToWebview() {
|
||||
if (checkIsUrl(props.url)) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/website/website?data=${JSON.stringify({
|
||||
title: props.title,
|
||||
url: encodeURIComponent(props.url),
|
||||
})}`,
|
||||
success: () => {
|
||||
handleOnChange(false)
|
||||
},
|
||||
})
|
||||
function formatDate(value?: string): string {
|
||||
if (!value)
|
||||
return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
async function handleCheckLatest() {
|
||||
if (checking.value)
|
||||
return
|
||||
checking.value = true
|
||||
try {
|
||||
const res = await getNoticeLatest()
|
||||
const latest = res.data
|
||||
if (!latest?.name || !latest.title)
|
||||
return
|
||||
// 今日已看过则不再打扰
|
||||
if (getCache<string>(todayKey(latest.name)))
|
||||
return
|
||||
notice.value = latest
|
||||
isShow.value = true
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取最新公告失败', err)
|
||||
}
|
||||
finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅关闭(不记录,下次进入可再弹) */
|
||||
function handleClose() {
|
||||
isShow.value = false
|
||||
}
|
||||
|
||||
/** 今日不再提醒:记录后关闭 */
|
||||
function handleDismissForever() {
|
||||
if (notice.value?.name) {
|
||||
setCache(todayKey(notice.value.name), '1')
|
||||
}
|
||||
isShow.value = false
|
||||
}
|
||||
|
||||
/** 查看全文:记录今日已看并跳公告详情页 */
|
||||
function handleViewAll() {
|
||||
if (notice.value?.name) {
|
||||
setCache(todayKey(notice.value.name), '1')
|
||||
const detailName = notice.value.name
|
||||
isShow.value = false
|
||||
uni.navigateTo({ url: `/pages-blog/notice/detail?name=${detailName}` })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleCheckLatest()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-popup v-model="isShow" width="80vw" position="center" custom-style="border-radius:12rpx;" @close="handleOnChange(false)">
|
||||
<view class="uh-notify-dialog box-border w-[80vw] p-3">
|
||||
<view class="text-overflow-2 mb-6 text-center font-bold">
|
||||
{{ title }}
|
||||
<wd-popup
|
||||
v-model="isShow"
|
||||
position="center"
|
||||
width="80vw"
|
||||
custom-style="border-radius:16rpx;"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view v-if="notice" class="uh-notify-dialog box-border w-[80vw] p-6">
|
||||
<!-- 头部:标题 + 关闭 -->
|
||||
<view class="flex items-center justify-between">
|
||||
<view class="flex items-center gap-2">
|
||||
<text class="text-[28rpx]">
|
||||
📢
|
||||
</text>
|
||||
<text class="text-[30rpx] font-bold text-[#333]">
|
||||
最新公告
|
||||
</text>
|
||||
<view
|
||||
v-if="notice.typeDisplayName"
|
||||
class="rounded px-1.5 py-0.5 text-[20rpx]"
|
||||
:style="{
|
||||
color: notice.typeColor || '#f83856',
|
||||
backgroundColor: notice.typeColor ? `${notice.typeColor}1a` : '#fdeef1',
|
||||
}"
|
||||
>
|
||||
{{ notice.typeDisplayName }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="flex h-8 w-8 items-center justify-center text-[#bbb]" @click="handleClose">
|
||||
<wd-icon name="close" size="16px" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="content-box">
|
||||
<scroll-view scroll-y :style="{ height: url ? '600rpx' : '680rpx' }">
|
||||
<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="content"
|
||||
:markdown="true"
|
||||
:show-line-number="true"
|
||||
:show-language-name="true"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 内容 -->
|
||||
<view class="mt-4">
|
||||
<view class="text-[32rpx] font-bold leading-snug text-[#222]">
|
||||
{{ notice.title }}
|
||||
</view>
|
||||
<view v-if="notice.summary" class="mt-3 text-[26rpx] leading-relaxed text-[#666]">
|
||||
{{ notice.summary }}
|
||||
</view>
|
||||
<view v-if="notice.publishTime" class="mt-3 text-[22rpx] text-[#bbb]">
|
||||
{{ formatDate(notice.publishTime) }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="url" class="mt-6 flex justify-center">
|
||||
<wd-button size="small" type="primary" @click="handleToWebview">
|
||||
点击跳转内容链接
|
||||
|
||||
<!-- 操作 -->
|
||||
<view class="mt-6 flex items-center justify-between border-t border-[#f5f5f5] pt-4">
|
||||
<view class="text-[24rpx] text-[#999]" @click="handleDismissForever">
|
||||
今日不再提醒
|
||||
</view>
|
||||
<wd-button size="small" type="primary" round @click="handleViewAll">
|
||||
查看全文 →
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -80,6 +147,6 @@ function handleToWebview() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.uh-notify-dialog {
|
||||
/* 无额外样式,布局全部由 UnoCSS 原子类实现 */
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -91,16 +91,16 @@ defineExpose({ refresh: handleGetData })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="uh-vote-card box-border w-full rounded-xl bg-white p-3 shadow-sm">
|
||||
<view class="uh-vote-card uh-global-card-glass box-border w-full rounded-2xl p-4">
|
||||
<view v-if="loading" class="loading py-6">
|
||||
<wd-skeleton :row="2" :animated="true" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="voteData" class="vote-body">
|
||||
<view class="vote-title text-[30rpx] text-[#303133] font-bold">
|
||||
<view class="vote-title text-[30rpx] text-gray-900 font-bold">
|
||||
{{ voteData.title }}
|
||||
</view>
|
||||
<view class="vote-desc mt-1 text-[24rpx] text-[#909399]">
|
||||
<view class="vote-desc mt-1 text-[24rpx] text-gray-400">
|
||||
{{ voteData.description }}
|
||||
</view>
|
||||
|
||||
@@ -108,15 +108,15 @@ defineExpose({ refresh: handleGetData })
|
||||
<view
|
||||
v-for="option in voteData.options"
|
||||
:key="option.name"
|
||||
class="option mb-4 flex flex-col border-2 border-transparent rounded-xl bg-[#f7f8fa] p-5"
|
||||
:class="{ active: voteTypes.includes(option.name || '') }"
|
||||
class="option mb-4 flex flex-col border-2 rounded-xl p-5"
|
||||
:class="voteTypes.includes(option.name || '') ? 'border-[#b9e424] bg-[#f0f7d9]' : 'border-transparent bg-[#f6f3ee]'"
|
||||
@click="handleSelectOption(option)"
|
||||
>
|
||||
<view class="option-label text-[28rpx] text-[#303133]">
|
||||
<view class="option-label text-[28rpx] text-gray-700">
|
||||
<text>{{ option.label }}</text>
|
||||
</view>
|
||||
<view v-if="voteState === VOTE_STATES.VOTED" class="option-bar mt-3 h-4 overflow-hidden rounded-lg bg-[#f0f0f0]">
|
||||
<view class="option-bar-inner h-full rounded-lg" :style="{ width: `${option.count || 0}%`, background: 'linear-gradient(90deg, #03a9f4, #64b5f6)' }" />
|
||||
<view v-if="voteState === VOTE_STATES.VOTED" class="option-bar mt-3 h-4 overflow-hidden rounded-lg bg-black/5">
|
||||
<view class="option-bar-inner h-full rounded-lg" :style="{ width: `${option.count || 0}%`, background: 'linear-gradient(90deg, #B9E424, #D7F94C)' }" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -126,20 +126,9 @@ defineExpose({ refresh: handleGetData })
|
||||
{{ voteLabel }}
|
||||
</wd-button>
|
||||
</view>
|
||||
<view v-else class="vote-tip mt-4 text-center text-[24rpx] text-[#909399]">
|
||||
<view v-else class="vote-tip mt-4 text-center text-[24rpx] text-gray-400">
|
||||
{{ voteLabel }}{{ voteResultLabel }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.uh-vote-card {
|
||||
.option {
|
||||
&.active {
|
||||
border-color: #03a9f4;
|
||||
background-color: rgb(3 169 244 / 8%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const markdownConfig = {
|
||||
video: 'width: 100%',
|
||||
},
|
||||
/** 容器样式 */
|
||||
containStyle: 'font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;padding:12px;font-size: 14px;color: #606266;word-spacing: 0.8px;letter-spacing: 0.8px;border-radius: 0px;background-color:transparent;',
|
||||
containStyle: 'font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;padding:0;font-size: 14px;color: #606266;word-spacing: 0.8px;letter-spacing: 0.8px;border-radius: 0px;background-color:transparent;',
|
||||
/** 加载图 / 空图(来自应用配置 imagesConfig) */
|
||||
loadingGif: checkImageUrl(undefined),
|
||||
emptyGif: checkImageUrl(undefined),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { useDataLoading } from './useDataLoading'
|
||||
|
||||
/**
|
||||
* 在 Vue 应用上下文中运行 composable。
|
||||
* composable 的 ref/computed 只能在 setup() 内使用,
|
||||
* withSetup 通过挂载一个临时组件来提供这个上下文。
|
||||
*/
|
||||
function withSetup<T>(composableFn: () => T): T {
|
||||
let result!: T
|
||||
const Comp = defineComponent({
|
||||
setup() {
|
||||
result = composableFn()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Comp)
|
||||
wrapper.unmount()
|
||||
return result
|
||||
}
|
||||
|
||||
describe('useDataLoading', () => {
|
||||
it('初始状态:status=loading, data=undefined', () => {
|
||||
const fetcher = vi.fn().mockResolvedValue('data')
|
||||
const { status, data } = withSetup(() => useDataLoading(fetcher))
|
||||
|
||||
expect(status.value).toBe('loading')
|
||||
expect(data.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('run 成功:status 从 loading 流转到 success,data 更新为返回值并触发 onSuccess', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue('result')
|
||||
const onSuccess = vi.fn()
|
||||
const { status, data, run } = withSetup(() => useDataLoading(fetcher, { onSuccess }))
|
||||
|
||||
const promise = run()
|
||||
expect(status.value).toBe('loading')
|
||||
|
||||
await promise
|
||||
expect(status.value).toBe('success')
|
||||
expect(data.value).toBe('result')
|
||||
expect(onSuccess).toHaveBeenCalledWith('result')
|
||||
})
|
||||
|
||||
it('run 返回空数组:status 流转到 empty', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue([])
|
||||
const { status, run } = withSetup(() => useDataLoading(fetcher))
|
||||
|
||||
await run()
|
||||
expect(status.value).toBe('empty')
|
||||
})
|
||||
|
||||
it('isEmpty 自定义判空:返回 true 时 status 为 empty', async () => {
|
||||
const fetcher = vi.fn(async (): Promise<{ total: number, items: number[] }> => ({ total: 0, items: [1] }))
|
||||
const { status, run } = withSetup(() => useDataLoading(fetcher, { isEmpty: data => data.total === 0 }))
|
||||
|
||||
await run()
|
||||
expect(status.value).toBe('empty')
|
||||
})
|
||||
|
||||
it('run 失败:不向外抛出异常,status 流转到 error 并触发 onError', async () => {
|
||||
const err = new Error('network error')
|
||||
const onError = vi.fn()
|
||||
const fetcher = vi.fn().mockRejectedValue(err)
|
||||
const { status, data, run } = withSetup(() => useDataLoading(fetcher, { onError }))
|
||||
|
||||
await expect(run()).resolves.toBeUndefined()
|
||||
|
||||
expect(status.value).toBe('error')
|
||||
expect(data.value).toBeUndefined()
|
||||
expect(onError).toHaveBeenCalledWith(err)
|
||||
})
|
||||
|
||||
it('带参数 run:参数透传给 fetcher', async () => {
|
||||
const fetcher = vi.fn(async (name: string) => `hi ${name}`)
|
||||
const { data, run } = withSetup(() => useDataLoading(fetcher))
|
||||
|
||||
await run('world')
|
||||
expect(fetcher).toHaveBeenCalledWith('world')
|
||||
expect(data.value).toBe('hi world')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 数据加载四态(取值与旧 DataLoadingStatusEnum 一致,二者可互相兼容) */
|
||||
export type DataLoadingStatus = 'loading' | 'error' | 'empty' | 'success'
|
||||
|
||||
export interface IUseDataLoadingOptions<T> {
|
||||
/** 自定义判空;默认:数组看长度、对象看键数、空值视为空 */
|
||||
isEmpty?: (data: T) => boolean
|
||||
/** 成功回调(数据就绪) */
|
||||
onSuccess?: (data: T) => void
|
||||
/** 失败回调(run 已捕获异常,不会向外抛出) */
|
||||
onError?: (err: unknown) => void
|
||||
}
|
||||
|
||||
export interface IUseDataLoadingReturn<T, P extends unknown[]> {
|
||||
data: Ref<T | undefined>
|
||||
status: Ref<DataLoadingStatus>
|
||||
run: (...args: P) => Promise<T | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据加载状态机
|
||||
* 页面只提供请求函数,loading/empty/success/error 状态由 hook 接管,
|
||||
* 取代「手工 ref + try/catch 里逐个搬运状态」的写法。
|
||||
*
|
||||
* @example
|
||||
* const { data, status, run } = useDataLoading(() => getMomentByName(name))
|
||||
* onLoad(() => run())
|
||||
* onPullDownRefresh(async () => { await run(); uni.stopPullDownRefresh() })
|
||||
*/
|
||||
export function useDataLoading<T, P extends unknown[] = []>(
|
||||
fetcher: (...args: P) => Promise<T>,
|
||||
options: IUseDataLoadingOptions<T> = {},
|
||||
): IUseDataLoadingReturn<T, P> {
|
||||
const { isEmpty, onSuccess, onError } = options
|
||||
|
||||
const data = ref<T | undefined>(undefined) as Ref<T | undefined>
|
||||
const status = ref<DataLoadingStatus>('loading')
|
||||
|
||||
function calcEmpty(value: T): boolean {
|
||||
if (isEmpty)
|
||||
return isEmpty(value)
|
||||
if (Array.isArray(value))
|
||||
return value.length === 0
|
||||
if (value !== null && typeof value === 'object')
|
||||
return Object.keys(value).length === 0
|
||||
return value === undefined || value === null || value === ''
|
||||
}
|
||||
|
||||
async function run(...args: P): Promise<T | undefined> {
|
||||
status.value = 'loading'
|
||||
try {
|
||||
const res = await fetcher(...args)
|
||||
data.value = res
|
||||
status.value = calcEmpty(res) ? 'empty' : 'success'
|
||||
onSuccess?.(res)
|
||||
return res
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[useDataLoading] 请求失败', err)
|
||||
status.value = 'error'
|
||||
onError?.(err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return { data, status, run }
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
export enum DataLoadingStatusEnum {
|
||||
Loading = 'loading',
|
||||
Error = 'error',
|
||||
Empty = 'empty',
|
||||
Success = 'success',
|
||||
}
|
||||
import type { DataLoadingStatus } from './useDataLoading'
|
||||
|
||||
export function useDataLoadingStatus(status?:DataLoadingStatusEnum) {
|
||||
const loadingStatus = ref<DataLoadingStatusEnum>(status??DataLoadingStatusEnum.Loading)
|
||||
/**
|
||||
* @deprecated 请改用 useDataLoading:状态由 hook 内部流转,
|
||||
* 无需再在 try/catch 里手工调用 updateLoadingStatus 搬运状态。
|
||||
* 本文件保留仅为兼容存量页面(category/article-detail 等),后续逐步迁移。
|
||||
*/
|
||||
export const DataLoadingStatusEnum = {
|
||||
Loading: 'loading',
|
||||
Error: 'error',
|
||||
Empty: 'empty',
|
||||
Success: 'success',
|
||||
} as const
|
||||
|
||||
export function useDataLoadingStatus(status?: DataLoadingStatus) {
|
||||
const loadingStatus = ref<DataLoadingStatus>(status ?? DataLoadingStatusEnum.Loading)
|
||||
|
||||
function resetLoadingStatus() {
|
||||
loadingStatus.value = DataLoadingStatusEnum.Loading
|
||||
}
|
||||
|
||||
function updateLoadingStatus(newStatus: DataLoadingStatusEnum) {
|
||||
function updateLoadingStatus(newStatus: DataLoadingStatus) {
|
||||
loadingStatus.value = newStatus
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 关于项目页(源自旧项目 pagesA/about,新建复刻)
|
||||
* 数字名片式设计:Hero 名片卡(渐变光斑透卡 + 徽章) + 彩色瓦片链接列表,点击复制
|
||||
*/
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
@@ -11,12 +12,12 @@ definePage({
|
||||
})
|
||||
|
||||
const links = [
|
||||
{ title: '开源组织', value: '巷子工坊', copy: 'https://www.ialley.cn', tip: '巷子工坊官网已复制成功!' },
|
||||
{ title: '开源作者', value: '小莫唐尼', copy: 'https://www.xiaoxiaomo.cn', tip: '作者主页地址已复制' },
|
||||
{ title: '作者博客', value: 'https://blog.xiaoxiaomo.cn', copy: 'https://blog.xiaoxiaomo.cn', tip: '作者博客地址已复制' },
|
||||
{ title: '文档地址', value: 'https://uni-halo.925i.cn', copy: 'https://uni-halo.925i.cn', tip: '项目码云仓库已复制' },
|
||||
{ title: '码云仓库', value: 'https://gitee.com/ialley-workshop-open/uni-halo', copy: 'https://gitee.com/ialley-workshop-open/uni-halo', tip: '码云仓库地址已复制' },
|
||||
{ title: 'Github', value: 'https://github.com/ialley-workshop-open/uni-halo', copy: 'https://github.com/ialley-workshop-open/uni-halo', tip: 'Github地址已复制' },
|
||||
{ title: '开源组织', value: '巷子工坊', copy: 'https://www.ialley.cn', tip: '巷子工坊官网已复制成功!', tileColor: '#5c6bc0', tileLetter: '组' },
|
||||
{ title: '开源作者', value: '小莫唐尼', copy: 'https://www.xiaoxiaomo.cn', tip: '作者主页地址已复制', tileColor: '#26a69a', tileLetter: '作' },
|
||||
{ title: '作者博客', value: 'https://blog.xiaoxiaomo.cn', copy: 'https://blog.xiaoxiaomo.cn', tip: '作者博客地址已复制', tileColor: '#7e57c2', tileLetter: '博' },
|
||||
{ title: '文档地址', value: 'https://uni-halo.925i.cn', copy: 'https://uni-halo.925i.cn', tip: '项目码云仓库已复制', tileColor: '#039be5', tileLetter: '文' },
|
||||
{ title: '码云仓库', value: 'https://gitee.com/ialley-workshop-open/uni-halo', copy: 'https://gitee.com/ialley-workshop-open/uni-halo', tip: '码云仓库地址已复制', tileColor: '#c71d23', tileLetter: '码' },
|
||||
{ title: 'Github', value: 'https://github.com/ialley-workshop-open/uni-halo', copy: 'https://github.com/ialley-workshop-open/uni-halo', tip: 'Github地址已复制', tileColor: '#24292f', tileLetter: 'G' },
|
||||
]
|
||||
|
||||
function copyText(content: string, tips: string) {
|
||||
@@ -35,31 +36,54 @@ onLoad(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page box-border h-screen w-screen flex flex-col items-center bg-white pt-10">
|
||||
<view class="logo mt-10 pt-10">
|
||||
<image class="logo-img h-[160rpx] w-[160rpx] rounded-xl" src="https://uni-halo.925i.cn/logo.png" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="mt-3 text-[36rpx] font-bold">
|
||||
uni-halo
|
||||
<view class="app-page box-border min-h-screen w-screen flex flex-col overflow-hidden bg-page px-4 pb-8 pt-6">
|
||||
<!-- Hero 名片卡(主题色光斑透过毛玻璃形成柔和渐变) -->
|
||||
<view class="hero-wrap relative">
|
||||
<view class="absolute h-[220rpx] w-[220rpx] rounded-full bg-[rgba(185,228,36,0.32)] -right-8 -top-8" />
|
||||
<view class="absolute top-[150rpx] h-[180rpx] w-[180rpx] rounded-full bg-[rgba(215,249,76,0.45)] -left-10" />
|
||||
<view class="hero-card uh-global-card-glass relative flex flex-col items-center rounded-3xl px-6 pb-7 pt-10">
|
||||
<image class="logo-img h-[140rpx] w-[140rpx] rounded-3xl shadow-lg" src="https://uni-halo.925i.cn/logo.png" mode="aspectFill" />
|
||||
<view class="mt-4 text-[40rpx] text-gray-900 font-bold">
|
||||
uni-halo
|
||||
</view>
|
||||
<view class="mt-3 flex items-center gap-2">
|
||||
<text class="rounded-full bg-secondary px-3 py-1 text-[20rpx] text-[#4d7c0f]">AGPL-3.0 开源协议</text>
|
||||
<text class="rounded-full bg-[#f6f3ee] px-3 py-1 text-[20rpx] text-gray-500">uni-app × Halo</text>
|
||||
</view>
|
||||
<view class="mt-4 text-center text-[24rpx] text-gray-500 leading-relaxed">
|
||||
基于 uni-app 打造的 Halo 博客跨端客户端
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-group mt-12 w-full">
|
||||
<!-- 相关链接 -->
|
||||
<uh-section-title class="mb-3 mt-6 text-[30rpx]">
|
||||
相关链接
|
||||
</uh-section-title>
|
||||
<view class="link-list uh-global-card-glass overflow-hidden rounded-2xl">
|
||||
<view
|
||||
v-for="link in links"
|
||||
v-for="(link, index) in links"
|
||||
:key="link.title"
|
||||
class="list-item flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7"
|
||||
class="link-item flex items-center gap-3 px-4 py-4"
|
||||
:class="index < links.length - 1 ? 'border-b border-black/5' : ''"
|
||||
@click="copyText(link.copy, link.tip)"
|
||||
>
|
||||
<text class="list-title w-[160rpx] shrink-0 text-[28rpx] text-[#303133]">{{ link.title }}</text>
|
||||
<text class="list-value max-w-[480rpx] overflow-hidden text-ellipsis whitespace-nowrap text-right text-[24rpx] text-[#909399]">{{ link.value }}</text>
|
||||
<view class="tile h-[76rpx] w-[76rpx] flex shrink-0 items-center justify-center rounded-xl border border-black/5" :style="{ backgroundColor: link.tileColor + '1A' }">
|
||||
<text class="text-[30rpx] font-bold" :style="{ color: link.tileColor }">{{ link.tileLetter }}</text>
|
||||
</view>
|
||||
<view class="min-w-0 flex flex-1 flex-col">
|
||||
<text class="text-[28rpx] text-gray-900 font-bold">{{ link.title }}</text>
|
||||
<view class="mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
|
||||
{{ link.value }}
|
||||
</view>
|
||||
</view>
|
||||
<wd-icon name="copy" size="28rpx" color="#c8c2b4" class="shrink-0" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="copyright fixed bottom-0 left-0 box-border w-screen bg-white p-9 text-center text-[22rpx] text-[#909399]">
|
||||
<view>根据 AGPL-3.0 协议开源</view>
|
||||
<view class="mt-2">
|
||||
「 2022 uni-halo 丨 开源项目丨巷子工坊@小莫唐尼 」
|
||||
</view>
|
||||
<!-- 版权 -->
|
||||
<view class="copyright mt-auto pt-8 text-center text-[22rpx] text-gray-400">
|
||||
<view>「 2022 uni-halo 丨 开源项目丨巷子工坊@小莫唐尼 」</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -27,7 +27,7 @@ const globalAppSettings = computed(() => settingStore.settings)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const tab = ref({ activeIndex: 0, list: ['按月份查看', '按年份查看'] })
|
||||
const activeTabIndex = ref(0)
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const result = ref<{ hasNext: boolean }>({ hasNext: false })
|
||||
const cacheDataList = ref<IPost[]>([])
|
||||
@@ -44,6 +44,17 @@ const loadMoreText = ref('加载中...')
|
||||
const postLabelYearKey = 'content.halo.run/archive-year'
|
||||
const postLabelMonthKey = 'content.halo.run/archive-month'
|
||||
|
||||
/** 卡片布局偏好 → 原子类(对应旧版 cardType 样式变体) */
|
||||
const CARD_LAYOUTS: Record<string, { card: string, thumb: string, info: string }> = {
|
||||
lr_image_text: { card: '', thumb: 'h-[170rpx] w-[200rpx]', info: 'w-0 flex-1 pl-5' },
|
||||
lr_text_image: { card: '', thumb: 'order-2 h-[170rpx] w-[200rpx]', info: 'order-1 w-0 flex-1 pr-5' },
|
||||
tb_image_text: { card: 'flex-col', thumb: 'h-[220rpx] w-full', info: 'w-full pt-3' },
|
||||
tb_text_image: { card: 'flex-col', thumb: 'order-2 h-[220rpx] w-full', info: 'order-1 w-full pb-3' },
|
||||
only_text: { card: '', thumb: 'hidden', info: 'py-1' },
|
||||
}
|
||||
|
||||
const calcCardLayout = computed(() => CARD_LAYOUTS[globalAppSettings.value.layout.cardType] || CARD_LAYOUTS.lr_image_text)
|
||||
|
||||
/* ---------------- 数据处理 ---------------- */
|
||||
/** 按 tab 分组文章 */
|
||||
function handleGetPosts(list: IPost[]): Record<string, IPost[]> {
|
||||
@@ -51,7 +62,7 @@ function handleGetPosts(list: IPost[]): Record<string, IPost[]> {
|
||||
list.forEach((item) => {
|
||||
const labels = item.metadata.labels || {}
|
||||
let postItemKey = ''
|
||||
if (tab.value.activeIndex === 0) {
|
||||
if (activeTabIndex.value === 0) {
|
||||
postItemKey = `${labels[postLabelYearKey]}-${labels[postLabelMonthKey]}`
|
||||
}
|
||||
else {
|
||||
@@ -78,7 +89,7 @@ function handleGetShowDataList(posts: Record<string, IPost[]>): typeof dataList.
|
||||
month: '',
|
||||
posts: posts[key],
|
||||
}
|
||||
if (tab.value.activeIndex === 0) {
|
||||
if (activeTabIndex.value === 0) {
|
||||
const splitDate = key.split('-')
|
||||
postData.year = splitDate[0]
|
||||
postData.month = splitDate[1]
|
||||
@@ -136,13 +147,14 @@ async function handleGetData() {
|
||||
loadMoreText.value = '加载中...'
|
||||
|
||||
try {
|
||||
const data = await getPostList({ ...queryParams.value })
|
||||
result.value = { hasNext: data.hasNext }
|
||||
const posts = handleGetPosts(data.items)
|
||||
// getPostList 返回 IResponse<IPostListRes>,数据在 .data 下
|
||||
const res = await getPostList({ ...queryParams.value })
|
||||
result.value = { hasNext: res.data.hasNext }
|
||||
const posts = handleGetPosts(res.data.items)
|
||||
const showDataList = handleGetShowDataList(posts)
|
||||
|
||||
if (isLoadMore.value) {
|
||||
cacheDataList.value = handleUniqueCacheDatalist([...cacheDataList.value, ...data.items])
|
||||
cacheDataList.value = handleUniqueCacheDatalist([...cacheDataList.value, ...res.data.items])
|
||||
// 合并增量数据
|
||||
showDataList.forEach((item) => {
|
||||
const find = dataList.value.find(x => x.key === item.key)
|
||||
@@ -163,11 +175,11 @@ async function handleGetData() {
|
||||
}
|
||||
else {
|
||||
dataList.value = showDataList
|
||||
cacheDataList.value = data.items
|
||||
cacheDataList.value = res.data.items
|
||||
}
|
||||
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
|
||||
loadMoreText.value = res.data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
@@ -180,8 +192,8 @@ async function handleGetData() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnTabChange(index: number) {
|
||||
tab.value.activeIndex = index
|
||||
function handleOnTabChange(e: { index: number }) {
|
||||
activeTabIndex.value = e.index
|
||||
queryParams.value.page = 1
|
||||
dataList.value = handleGetShowDataList(handleGetPosts(cacheDataList.value))
|
||||
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
|
||||
@@ -237,17 +249,14 @@ onReachBottom(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
|
||||
<!-- 顶部 tab -->
|
||||
<view class="archive-tabs fixed inset-x-0 top-0 z-6 bg-white">
|
||||
<wd-tabs
|
||||
v-model="tab.activeIndex"
|
||||
:tabs="tab.list.map(title => ({ title }))"
|
||||
align="center"
|
||||
@change="handleOnTabChange"
|
||||
/>
|
||||
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
|
||||
<!-- 顶部 tab(玻璃吸顶,wd-tabs 需用 wd-tab 子组件声明页签) -->
|
||||
<view class="archive-tabs uh-global-card-glass sticky top-0 z-10">
|
||||
<wd-tabs v-model="activeTabIndex" align="center" custom-style="background: transparent;" @change="handleOnTabChange">
|
||||
<wd-tab title="按月份查看" />
|
||||
<wd-tab title="按年份查看" />
|
||||
</wd-tabs>
|
||||
</view>
|
||||
<view class="h-[90rpx] w-screen" />
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loading !== 'success'" class="loading-wrap p-3">
|
||||
@@ -256,105 +265,59 @@ onReachBottom(() => {
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<block v-else>
|
||||
<view v-if="dataList.length === 0" class="list-empty h-screen w-screen flex items-center justify-center">
|
||||
<view v-if="dataList.length === 0" class="list-empty min-h-[60vh] flex items-center justify-center">
|
||||
<wd-empty :description="calcAuditModeEnabled ? '暂无归档的内容' : '暂无归档的文章'" />
|
||||
</view>
|
||||
|
||||
<view v-else class="timeline mt-6 px-6">
|
||||
<!-- 时间线 -->
|
||||
<view v-else class="timeline px-4 pt-3">
|
||||
<view v-for="(item, index) in dataList" :key="item.key" class="timeline-item flex">
|
||||
<view class="timeline-left w-[160rpx] flex shrink-0 flex-col items-center">
|
||||
<view class="timeline-dot mt-1 h-6 w-6 rounded-full" style="background-color: #64b5f6; box-shadow: 0 4rpx 12rpx rgb(100 181 246 / 40%);" />
|
||||
<view v-if="index !== dataList.length - 1" class="timeline-line mt-1 w-0.5 flex-1 bg-[#e0e0e0]" />
|
||||
<view class="timeline-left w-[96rpx] flex shrink-0 flex-col items-center">
|
||||
<view class="timeline-dot mt-2 h-4 w-4 rounded-full bg-secondary shadow-[0_0_0_8rpx_rgba(215,249,76,0.3)]" />
|
||||
<view v-if="index !== dataList.length - 1" class="timeline-line mt-2 w-[2rpx] flex-1 bg-black/5" />
|
||||
</view>
|
||||
<view class="timeline-content flex-1 pb-12 pl-6">
|
||||
<view class="time mb-6 flex items-center font-bold">
|
||||
<text class="time-text text-[30rpx]">{{ item.year }}年</text>
|
||||
<text v-if="tab.activeIndex === 0" class="time-text text-[30rpx]">{{ item.month }}月</text>
|
||||
<text class="time-count ml-3 text-[22rpx] text-[#999] font-normal">(共 {{ item.posts.length }} 篇{{ calcAuditModeEnabled ? '内容' : '文章' }})</text>
|
||||
<view class="timeline-content min-w-0 flex-1 pb-10 pl-5">
|
||||
<view class="time mb-5 flex items-center gap-2">
|
||||
<text class="text-[32rpx] text-gray-900 font-bold">{{ item.year }}年</text>
|
||||
<text v-if="activeTabIndex === 0" class="text-[32rpx] text-gray-900 font-bold">{{ item.month }}月</text>
|
||||
<text class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">共 {{ item.posts.length }} 篇{{ calcAuditModeEnabled ? '内容' : '文章' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="item.posts.length !== 0">
|
||||
<view
|
||||
v-for="post in item.posts"
|
||||
:key="post.metadata.name"
|
||||
class="post mb-6 flex rounded-xl bg-white p-6 shadow-sm"
|
||||
:class="[globalAppSettings.layout.cardType]"
|
||||
class="post uh-global-card-glass mb-4 flex rounded-2xl p-4"
|
||||
:class="calcCardLayout.card"
|
||||
@click="handleToArticleDetail(post)"
|
||||
>
|
||||
<image class="post-thumbnail h-[170rpx] w-[200rpx] shrink-0 rounded-lg" :src="checkThumbnailUrl(post.spec.cover)" mode="aspectFill" lazy-load />
|
||||
<view class="post-info w-0 flex-1 pl-5">
|
||||
<view class="post-info-title text-overflow text-[28rpx] text-[#303133] font-bold">
|
||||
<image class="post-thumbnail shrink-0 rounded-lg" :class="calcCardLayout.thumb" :src="checkThumbnailUrl(post.spec.cover)" mode="aspectFill" lazy-load />
|
||||
<view class="post-info min-w-0" :class="calcCardLayout.info">
|
||||
<view class="post-info-title overflow-hidden text-ellipsis whitespace-nowrap text-[28rpx] text-gray-900 font-bold">
|
||||
{{ post.spec.title }}
|
||||
</view>
|
||||
<view class="post-info-summary line-clamp-2 mt-3 text-[24rpx] text-[#909399]">
|
||||
<view class="post-info-summary line-clamp-2 mt-2 text-[24rpx] text-gray-400">
|
||||
{{ post.status?.excerpt }}
|
||||
</view>
|
||||
<view class="post-info-time mt-3 text-[24rpx] text-[#909399]">
|
||||
<view class="post-info-time mt-2 text-[24rpx] text-gray-400">
|
||||
发布时间:{{ formatTime(post.spec.publishTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="post-empty py-6 text-[26rpx] text-[#909399]">
|
||||
<view v-else class="post-empty py-6 text-[26rpx] text-gray-400">
|
||||
该日期下暂无归档文章!
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-text pb-6 text-center text-[24rpx] text-[#999]">
|
||||
<view class="load-text pb-6 text-center text-[24rpx] text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-page {
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
|
||||
.timeline {
|
||||
.post {
|
||||
&.tb_image_text,
|
||||
&.tb_text_image {
|
||||
flex-direction: column;
|
||||
|
||||
.post-thumbnail {
|
||||
width: 100%;
|
||||
height: 220rpx;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.lr_text_image {
|
||||
.post-thumbnail {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
order: 1;
|
||||
padding-left: 0;
|
||||
padding-right: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.only_text {
|
||||
.post-thumbnail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
padding: 6rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 联系博主页(源自旧项目 pagesA/contact,新建复刻)
|
||||
* 展示博主社交联系方式,点击复制
|
||||
* 数字名片式设计:Hero 名片卡(渐变光斑透卡) + 品牌色字母瓦片联系方式列表,点击复制
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
@@ -42,6 +42,20 @@ const result = ref<{ key: string, name: string, value: string }[]>([
|
||||
{ key: 'email', name: '邮箱地址', value: '' },
|
||||
])
|
||||
|
||||
/** 平台瓦片(品牌色 + 字符);未覆盖的平台回退中性色与名称首字 */
|
||||
const platformMeta: Record<string, { color: string, letter: string }> = {
|
||||
qq: { color: '#12b7f5', letter: 'Q' },
|
||||
wechat: { color: '#07c160', letter: '微' },
|
||||
github: { color: '#24292f', letter: 'G' },
|
||||
gitee: { color: '#c71d23', letter: 'G' },
|
||||
bilibili: { color: '#fb7299', letter: 'B' },
|
||||
csdn: { color: '#fc5531', letter: 'C' },
|
||||
blog: { color: '#3f51b5', letter: '博' },
|
||||
juejin: { color: '#1e80ff', letter: '掘' },
|
||||
weibo: { color: '#e6162d', letter: 'W' },
|
||||
email: { color: '#f57c00', letter: '@' },
|
||||
}
|
||||
|
||||
const calcIsNotEmpty = computed(() => result.value.some(item => item.value !== ''))
|
||||
|
||||
function handleGetData() {
|
||||
@@ -77,46 +91,55 @@ onLoad(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page box-border min-h-screen w-screen flex flex-col items-center bg-white pt-[160rpx]">
|
||||
<!-- 博主信息 -->
|
||||
<view class="profile flex flex-col items-center p-9">
|
||||
<view class="avatar relative box-border h-[170rpx] w-[170rpx] overflow-hidden border-6 border-white rounded-full shadow-sm">
|
||||
<image class="avatar-img h-full w-full" :src="bloggerInfo.avatar" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="nickname mt-6 text-[38rpx] font-bold">
|
||||
{{ bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="desc mt-6 text-center text-[26rpx] text-[#666]">
|
||||
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
|
||||
<view class="app-page box-border min-h-screen w-screen overflow-hidden bg-page px-4 pb-10 pt-6">
|
||||
<!-- Hero 名片卡(主题色光斑透过毛玻璃形成柔和渐变) -->
|
||||
<view class="hero-wrap relative">
|
||||
<view class="absolute h-[220rpx] w-[220rpx] rounded-full bg-[rgba(185,228,36,0.32)] -right-8 -top-8" />
|
||||
<view class="absolute top-[150rpx] h-[180rpx] w-[180rpx] rounded-full bg-[rgba(215,249,76,0.45)] -left-10" />
|
||||
<view class="hero-card uh-global-card-glass relative flex flex-col items-center rounded-3xl px-6 pb-6 pt-10">
|
||||
<view class="avatar relative box-border h-[150rpx] w-[150rpx] overflow-hidden border-5 border-white/90 rounded-full shadow-lg">
|
||||
<image class="avatar-img h-full w-full" :src="bloggerInfo.avatar" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="nickname mt-4 text-[38rpx] text-gray-900 font-bold">
|
||||
{{ bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="desc mt-2 px-4 text-center text-[26rpx] text-gray-500 leading-relaxed">
|
||||
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
|
||||
</view>
|
||||
<view class="mt-5 w-full flex items-center justify-center gap-2 border-t border-black/5 pt-4">
|
||||
<wd-icon name="copy" size="26rpx" color="#a8a294" />
|
||||
<text class="text-2xs text-gray-400">点击卡片即可复制对应内容</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 联系方式列表 -->
|
||||
<view class="contact box-border w-full px-12 pt-12" style="border-top: 2rpx solid #f2f2f2;">
|
||||
<block v-if="calcIsNotEmpty">
|
||||
<!-- 联系方式 -->
|
||||
<block v-if="calcIsNotEmpty">
|
||||
<uh-section-title class="mb-3 mt-6 text-[30rpx]">
|
||||
联系方式
|
||||
</uh-section-title>
|
||||
<view class="flex flex-col gap-3">
|
||||
<view
|
||||
v-for="item in result.filter(i => i.value)"
|
||||
:key="item.key"
|
||||
class="item mt-6 box-border rounded-xl bg-[#fafafa] p-4"
|
||||
class="item uh-global-card-glass flex items-center gap-3 rounded-2xl px-4 py-3"
|
||||
@click="handleOnClick(item)"
|
||||
>
|
||||
<view class="left box-border w-[160rpx] flex items-center">
|
||||
<text class="name text-[24rpx] text-[#555]">{{ item.name }}</text>
|
||||
<view class="tile h-[76rpx] w-[76rpx] flex shrink-0 items-center justify-center rounded-xl" :style="{ backgroundColor: platformMeta[item.key]?.color || '#8a8a7a' }">
|
||||
<text class="text-[30rpx] text-white font-bold">{{ platformMeta[item.key]?.letter || item.name.slice(0, 1) }}</text>
|
||||
</view>
|
||||
<view class="right box-border w-0 flex flex-1 flex-wrap items-center break-all pl-3 text-[24rpx] text-[#333]">
|
||||
{{ item.value }}
|
||||
<view class="min-w-0 flex flex-1 flex-col">
|
||||
<text class="text-[24rpx] text-gray-400">{{ item.name }}</text>
|
||||
<view class="mt-1 break-all text-[26rpx] text-gray-900 leading-snug">
|
||||
{{ item.value }}
|
||||
</view>
|
||||
</view>
|
||||
<wd-icon name="copy" size="28rpx" color="#c8c2b4" class="shrink-0" />
|
||||
</view>
|
||||
</block>
|
||||
<view v-else class="empty pt-12">
|
||||
<wd-empty description="暂无联系方式" />
|
||||
</view>
|
||||
</block>
|
||||
<view v-else class="pt-12">
|
||||
<wd-empty description="暂无联系方式" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-page {
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -320,10 +320,10 @@ onReachBottom(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
|
||||
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
|
||||
<!-- 顶部 tabs -->
|
||||
<view class="tabs-wrap sticky top-0 z-10 bg-white px-6">
|
||||
<wd-tabs v-model="activeTabIndex" align="left" @change="handleOnTabChange">
|
||||
<view class="tabs-wrap uh-global-card-glass sticky top-0 z-10 px-6">
|
||||
<wd-tabs v-model="activeTabIndex" align="left" custom-style="background: transparent;" @change="handleOnTabChange">
|
||||
<wd-tab title="站点" />
|
||||
<wd-tab v-if="!appConfigStore.auditModeEnabled" title="小程序" />
|
||||
</wd-tabs>
|
||||
@@ -342,43 +342,42 @@ onReachBottom(() => {
|
||||
<wd-skeleton :row="5" :animated="true" />
|
||||
</view>
|
||||
|
||||
<view v-else class="content pt-6" :class="{ 'bg-white': dataList.length !== 0 }">
|
||||
<view v-else class="content pt-4">
|
||||
<view v-if="dataList.length === 0" class="h-[60vh] flex items-center justify-center content-empty">
|
||||
<wd-empty description="啊偶,博主还没有朋友呢~" />
|
||||
</view>
|
||||
|
||||
<!-- 友链列表 -->
|
||||
<view v-else class="link-list px-6">
|
||||
<view v-for="(link, index) in dataList" :key="index">
|
||||
<view v-else class="link-list flex flex-col gap-4 px-4 pb-4">
|
||||
<view v-for="link in dataList" :key="link.metadata?.name || link.spec.displayName">
|
||||
<!-- 色彩版 -->
|
||||
<view
|
||||
v-if="!globalAppSettings.links.useSimple"
|
||||
class="info flex bg-white p-3"
|
||||
:class="{ 'border-b-2 border-[#f5f5f5]': index !== dataList.length - 1 }"
|
||||
class="info uh-global-card-glass flex rounded-2xl p-3"
|
||||
@click="handleOnLinkEvent(link)"
|
||||
>
|
||||
<image class="link-logo h-[140rpx] w-[140rpx] shrink-0 rounded-xl" :src="link.spec.logo" mode="aspectFill" />
|
||||
<view class="info-detail flex flex-1 flex-col justify-center pl-7">
|
||||
<view class="link-card-name text-[30rpx] text-[#f44336] font-bold">
|
||||
<text class="group-tag mr-3 rounded-md px-1.5 py-0.5 text-[20rpx] text-white font-normal" style="background: linear-gradient(135deg, #64b5f6, #2196f3);">{{ link.spec.groupName || '暂未分组' }}</text>
|
||||
<view class="info-detail flex flex-1 flex-col justify-center pl-5">
|
||||
<view class="link-card-name text-[30rpx] text-gray-900 font-bold">
|
||||
<text class="group-tag mr-3 rounded-md bg-secondary px-1.5 py-0.5 text-[20rpx] text-[#4d7c0f] font-normal">{{ link.spec.groupName || '暂未分组' }}</text>
|
||||
{{ link.spec.displayName }}
|
||||
</view>
|
||||
<view class="link-card-url mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#666]">
|
||||
<view class="link-card-url mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
|
||||
站点地址:{{ link.spec.url }}
|
||||
</view>
|
||||
<view class="link-card-desc mt-2 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#303133]">
|
||||
<view class="link-card-desc mt-2 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-600">
|
||||
博客简介:{{ link.spec.description || '这个博主很懒,没写简介~' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 简洁版 -->
|
||||
<view v-else class="link-card mb-6 flex items-center rounded-xl bg-white p-6 shadow-sm" @click="handleOnLinkEvent(link)">
|
||||
<image class="logo h-[80rpx] w-[80rpx] shrink-0 border-6 border-white rounded-xl" :src="link.spec.logo" mode="aspectFill" />
|
||||
<view class="link-info flex-1 pl-6">
|
||||
<view class="name text-[30rpx] text-[#303133] font-bold">
|
||||
<view v-else class="link-card uh-global-card-glass flex items-center rounded-2xl p-4" @click="handleOnLinkEvent(link)">
|
||||
<image class="logo h-[80rpx] w-[80rpx] shrink-0 border-4 border-white/90 rounded-xl" :src="link.spec.logo" mode="aspectFill" />
|
||||
<view class="link-info flex-1 pl-5">
|
||||
<view class="name text-[30rpx] text-gray-900 font-bold">
|
||||
{{ link.spec.displayName }}
|
||||
</view>
|
||||
<view class="desc mt-3 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#909399]">
|
||||
<view class="desc mt-2 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
|
||||
{{ link.spec.description }}
|
||||
</view>
|
||||
</view>
|
||||
@@ -388,39 +387,39 @@ onReachBottom(() => {
|
||||
|
||||
<!-- 悬浮按钮 -->
|
||||
<view class="flot-buttons fixed bottom-[100rpx] right-8 z-999 flex flex-col gap-1.5">
|
||||
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
<view class="fab-btn uh-global-card-glass h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
<view v-if="(haloPluginConfigs?.linksSubmitPlugin as { enabled?: boolean } | undefined)?.enabled" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="toSubmitLinkPage">
|
||||
<wd-icon name="edit" size="20px" color="#ff9800" />
|
||||
<view v-if="(haloPluginConfigs?.linksSubmitPlugin as { enabled?: boolean } | undefined)?.enabled" class="fab-btn uh-global-card-glass h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="toSubmitLinkPage">
|
||||
<wd-icon name="edit" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<wd-popup v-model="detail.show" position="center" custom-style="width:640rpx;border-radius:12rpx;">
|
||||
<view v-if="detail.data" class="poup p-9">
|
||||
<view v-if="detail.data" class="poup p-8">
|
||||
<view class="info flex">
|
||||
<image class="poup-logo h-[140rpx] w-[140rpx] shrink-0 rounded-full" :src="checkImageUrl(detail.data.spec.logo)" mode="aspectFill" />
|
||||
<view class="poup-info ml-6 flex flex-1 flex-col justify-center">
|
||||
<view class="poup-name text-[34rpx] font-bold">
|
||||
<view class="poup-name text-[34rpx] text-gray-900 font-bold">
|
||||
{{ detail.data.spec.displayName }}
|
||||
</view>
|
||||
<view class="poup-tag mt-2 text-[24rpx] text-[#999]">
|
||||
<view class="poup-tag mt-2 text-[24rpx] text-gray-400">
|
||||
{{ detail.data.spec.groupName }}
|
||||
</view>
|
||||
<view class="poup-link mt-3" @click="handleCopyLink(detail.data)">
|
||||
<text class="poup-url text-[24rpx] text-[#ff9800]">{{ detail.data.spec.url }}</text>
|
||||
<text class="poup-url rounded-lg bg-secondary px-2 py-1 text-[24rpx] text-[#4d7c0f]">{{ detail.data.spec.url }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="poup-desc mt-5 text-[28rpx] text-[#555] leading-[1.6]">
|
||||
<view class="poup-desc mt-5 text-[28rpx] text-gray-600 leading-[1.6]">
|
||||
博客简介:{{ detail.data.spec.description || '这个博主很懒,没写简介~' }}
|
||||
</view>
|
||||
<image class="poup-img mt-6 h-[320rpx] w-[568rpx] rounded-xl" :src="calcSiteThumbnail(detail.data.spec.url)" mode="aspectFill" />
|
||||
<image class="poup-img mt-6 h-[320rpx] w-full rounded-xl" :src="calcSiteThumbnail(detail.data.spec.url)" mode="aspectFill" />
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
|
||||
<view class="load-text py-5 text-center text-[24rpx] text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</view>
|
||||
@@ -450,15 +449,15 @@ onReachBottom(() => {
|
||||
<view v-else class="mini-link-list flex-1 px-6 py-4">
|
||||
<view v-for="group in miniGroups" :key="group.groupName || 'ungrouped'" class="group-item mb-8">
|
||||
<view class="group-title mb-4 flex items-center">
|
||||
<text class="mr-2 inline-block h-[28rpx] w-[8rpx] rounded-full" style="background-color:#2196f3;" />
|
||||
<text class="text-[30rpx] text-[#303133] font-bold">{{ group.displayName || '未分组' }}</text>
|
||||
<text class="ml-3 text-[24rpx] text-[#999]">({{ group.links.length }})</text>
|
||||
<text class="mr-2 inline-block h-[28rpx] w-[8rpx] rounded-full bg-secondary" />
|
||||
<text class="text-[30rpx] text-gray-900 font-bold">{{ group.displayName || '未分组' }}</text>
|
||||
<text class="ml-3 text-[24rpx] text-gray-400">({{ group.links.length }})</text>
|
||||
</view>
|
||||
<view class="group-cards flex flex-col gap-4">
|
||||
<view
|
||||
v-for="link in group.links"
|
||||
:key="link.metadata?.name"
|
||||
class="mini-card flex items-center rounded-xl bg-white p-4 shadow-sm"
|
||||
class="mini-card uh-global-card-glass flex items-center rounded-2xl p-4"
|
||||
@click="handleOnMiniLinkEvent(link)"
|
||||
>
|
||||
<image
|
||||
@@ -467,17 +466,17 @@ onReachBottom(() => {
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="mini-info flex flex-1 flex-col pl-5">
|
||||
<view class="mini-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#303133] font-bold">
|
||||
<view class="mini-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-gray-900 font-bold">
|
||||
{{ link.spec?.displayName }}
|
||||
</view>
|
||||
<view v-if="link.spec?.authorName" class="mini-author mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#999]">
|
||||
<view v-if="link.spec?.authorName" class="mini-author mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
|
||||
{{ link.spec.authorName }}
|
||||
</view>
|
||||
<view class="mini-desc mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#666]">
|
||||
<view class="mini-desc mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-500">
|
||||
{{ link.spec?.description || '暂无简介~' }}
|
||||
</view>
|
||||
</view>
|
||||
<wd-icon name="arrow-right" size="16px" color="#c0c4cc" />
|
||||
<wd-icon name="arrow-right" size="16px" color="#c8c2b4" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -485,7 +484,7 @@ onReachBottom(() => {
|
||||
|
||||
<!-- 申请收录悬浮按钮 -->
|
||||
<view class="apply-btn-wrap fixed bottom-[100rpx] right-8 z-999">
|
||||
<view class="apply-btn h-[88rpx] flex items-center rounded-full bg-[#2196f3] px-6 shadow-sm" @click="handleOpenApply">
|
||||
<view class="apply-btn h-[88rpx] flex items-center rounded-full bg-gray-900 px-6" @click="handleOpenApply">
|
||||
<wd-icon name="add" size="20px" color="#fff" />
|
||||
<text class="ml-2 text-[26rpx] text-white">申请收录</text>
|
||||
</view>
|
||||
@@ -504,42 +503,42 @@ onReachBottom(() => {
|
||||
@click="handlePreviewMiniProgramCode(miniDetail.data)"
|
||||
@longpress="handleSaveMiniProgramCode(miniDetail.data)"
|
||||
/>
|
||||
<view class="code-tip mt-3 flex items-center text-[24rpx] text-[#999]">
|
||||
<wd-icon name="picture" size="14px" color="#999" />
|
||||
<view class="code-tip mt-3 flex items-center text-[24rpx] text-gray-400">
|
||||
<wd-icon name="picture" size="14px" color="#a8a294" />
|
||||
<text class="ml-1">点击预览,长按保存太阳码</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 名称与分组 -->
|
||||
<view class="mini-head mt-5 flex items-center">
|
||||
<text class="mini-name text-[34rpx] text-[#303133] font-bold">{{ miniDetail.data.spec?.displayName }}</text>
|
||||
<text v-if="miniDetail.data.spec?.groupName" class="group-tag ml-3 rounded-md px-2 py-0.5 text-[20rpx] text-white" style="background: linear-gradient(135deg, #64b5f6, #2196f3);">
|
||||
<text class="mini-name text-[34rpx] text-gray-900 font-bold">{{ miniDetail.data.spec?.displayName }}</text>
|
||||
<text v-if="miniDetail.data.spec?.groupName" class="group-tag ml-3 rounded-md bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f]">
|
||||
{{ miniDetail.data.spec.groupName }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 描述 -->
|
||||
<view v-if="miniDetail.data.spec?.description" class="mini-desc mt-4 text-[28rpx] text-[#555] leading-[1.6]">
|
||||
<view v-if="miniDetail.data.spec?.description" class="mini-desc mt-4 text-[28rpx] text-gray-600 leading-[1.6]">
|
||||
{{ miniDetail.data.spec.description }}
|
||||
</view>
|
||||
|
||||
<!-- 作者信息 -->
|
||||
<view v-if="miniDetail.data.spec?.authorName || miniDetail.data.spec?.avatar || miniDetail.data.spec?.website" class="mini-author-info mt-5 flex items-center rounded-xl bg-[#f5f5f5] p-4">
|
||||
<view v-if="miniDetail.data.spec?.authorName || miniDetail.data.spec?.avatar || miniDetail.data.spec?.website" class="mini-author-info mt-5 flex items-center rounded-xl bg-[#f6f3ee] p-4">
|
||||
<image v-if="miniDetail.data.spec?.avatar" class="author-avatar h-[72rpx] w-[72rpx] shrink-0 rounded-full" :src="checkAvatarUrl(miniDetail.data.spec.avatar)" mode="aspectFill" />
|
||||
<view class="author-detail ml-4 flex flex-1 flex-col">
|
||||
<text v-if="miniDetail.data.spec?.authorName" class="author-name text-[28rpx] text-[#303133] font-medium">{{ miniDetail.data.spec.authorName }}</text>
|
||||
<text v-if="miniDetail.data.spec?.website" class="author-website mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#999]" @click="handleCopyMiniProgramCode(miniDetail.data)">
|
||||
<text v-if="miniDetail.data.spec?.authorName" class="author-name text-[28rpx] text-gray-900 font-medium">{{ miniDetail.data.spec.authorName }}</text>
|
||||
<text v-if="miniDetail.data.spec?.website" class="author-website mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400" @click="handleCopyMiniProgramCode(miniDetail.data)">
|
||||
网站:{{ miniDetail.data.spec.website }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 小程序地址 -->
|
||||
<view v-if="miniDetail.data.spec?.link" class="mini-link mt-5 flex items-center justify-between rounded-xl bg-[#fff8f0] p-4">
|
||||
<view class="link-text flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#ff9800]">
|
||||
<view v-if="miniDetail.data.spec?.link" class="mini-link mt-5 flex items-center justify-between rounded-xl bg-secondary p-4">
|
||||
<view class="link-text flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#4d7c0f]">
|
||||
{{ miniDetail.data.spec.link }}
|
||||
</view>
|
||||
<text class="ml-3 shrink-0 text-[26rpx] text-[#ff9800]" @click="handleCopyMiniProgramCode(miniDetail.data)">复制</text>
|
||||
<text class="ml-3 shrink-0 text-[26rpx] text-[#4d7c0f] font-bold" @click="handleCopyMiniProgramCode(miniDetail.data)">复制</text>
|
||||
</view>
|
||||
|
||||
<!-- 预览图轮播 -->
|
||||
@@ -559,9 +558,3 @@ onReachBottom(() => {
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-page {
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* 情侣信息 + 恋爱计时 + 功能导航(恋爱故事/相册/清单)
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getLoveConfig } from '@/api/uni-halo'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
|
||||
@@ -70,41 +69,45 @@ const loveWrapStyle = computed(() => ({
|
||||
}))
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetLoveConfig() {
|
||||
try {
|
||||
const loveConfigRes = await getLoveConfig()
|
||||
if (loveConfigRes) {
|
||||
loveConfig.value = {
|
||||
...loveConfig.value,
|
||||
...loveConfigRes,
|
||||
}
|
||||
}
|
||||
// 同时从 getConfigs 获取模块开关和图片配置
|
||||
const appConfigs = appConfigStore.configs
|
||||
const loveModuleConfig = appConfigs.loveConfig as Partial<ILoveConfigPage> | undefined
|
||||
if (loveModuleConfig) {
|
||||
loveConfig.value = {
|
||||
...loveConfig.value,
|
||||
pageImages: loveModuleConfig.pageImages || loveConfig.value.pageImages,
|
||||
ourStory: loveModuleConfig.ourStory || loveConfig.value.ourStory,
|
||||
lovePhoto: loveModuleConfig.lovePhoto || loveConfig.value.lovePhoto,
|
||||
loveDaily: loveModuleConfig.loveDaily || loveConfig.value.loveDaily,
|
||||
}
|
||||
}
|
||||
initList()
|
||||
handleInitLoveDayCount()
|
||||
/**
|
||||
* 恋爱配置数据统一来自 appConfig store(静态配置一次性拉取,设计见
|
||||
* .docs/static-config-unified-fetch-design.md):
|
||||
* - loveConfig(公开 /love-config 内容: enabled/纪念日/恋人信息, bootstrap 已拉取)
|
||||
* - configs.loveConfig(模块开关与页面图片, getConfigs 合成下发)
|
||||
* 页面不再自行发起 /love-config 请求,也不再需要接口失败降级分支(store 兜底默认)。
|
||||
*/
|
||||
function syncLoveConfigFromStore() {
|
||||
const storeLove = appConfigStore.loveConfig
|
||||
const appConfigs = appConfigStore.configs
|
||||
|
||||
loveConfig.value = {
|
||||
...loveConfig.value,
|
||||
...(storeLove.loveDateTitle ? { loveDateTitle: storeLove.loveDateTitle } : {}),
|
||||
...(storeLove.loveDate ? { loveDate: storeLove.loveDate } : {}),
|
||||
...(storeLove.enabled !== undefined ? { enabled: storeLove.enabled } : {}),
|
||||
loveInfo: {
|
||||
...loveConfig.value.loveInfo,
|
||||
...(storeLove.loveInfo || {}),
|
||||
},
|
||||
}
|
||||
catch (e) {
|
||||
console.error('获取恋爱配置失败', e)
|
||||
// 降级:从旧配置读取
|
||||
const appConfigs = appConfigStore.configs
|
||||
const loveModuleConfig = appConfigs.loveConfig as ILoveConfigPage | undefined
|
||||
if (loveModuleConfig) {
|
||||
loveConfig.value = loveModuleConfig
|
||||
initList()
|
||||
handleInitLoveDayCount()
|
||||
|
||||
// 从 getConfigs.loveConfig 取模块开关与页面图片(缺省保留默认)
|
||||
const loveModuleConfig = appConfigs.loveConfig as Partial<ILoveConfigPage> | undefined
|
||||
if (loveModuleConfig) {
|
||||
loveConfig.value = {
|
||||
...loveConfig.value,
|
||||
pageImages: loveModuleConfig.pageImages || loveConfig.value.pageImages,
|
||||
ourStory: loveModuleConfig.ourStory || loveConfig.value.ourStory,
|
||||
lovePhoto: loveModuleConfig.lovePhoto || loveConfig.value.lovePhoto,
|
||||
loveDaily: loveModuleConfig.loveDaily || loveConfig.value.loveDaily,
|
||||
}
|
||||
}
|
||||
|
||||
initList()
|
||||
// 未配置纪念日(loveDate 为空)不启动倒计时,避免 NaN
|
||||
if (loveConfig.value.loveDate) {
|
||||
handleInitLoveDayCount()
|
||||
}
|
||||
}
|
||||
|
||||
function initList() {
|
||||
@@ -169,7 +172,14 @@ function handleToPage(pageName: string) {
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(() => {
|
||||
uni.setNavigationBarTitle({ title: '恋爱日记' })
|
||||
handleGetLoveConfig()
|
||||
// 先用 store 已有数据立即渲染(index 启动已 bootstrap 或持久化缓存恢复)
|
||||
syncLoveConfigFromStore()
|
||||
})
|
||||
|
||||
// 每次进入页面:静态配置 TTL 内不重复请求(bootstrap 内部判定),刷新后重新合成视图
|
||||
onShow(async () => {
|
||||
await appConfigStore.bootstrap()
|
||||
syncLoveConfigFromStore()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
/**
|
||||
* 瞬间详情页(源自旧项目 pagesA/moment-detail,新建复刻)
|
||||
* 展示瞬间内容(mp-html) + 图片/音频/视频附件
|
||||
* 设计:日记页(大字日期刊头 + 宽松留白),与列表页的信息流形成两种性格
|
||||
* 试点:useDataLoading 状态机接管加载四态
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import dayjs from 'dayjs'
|
||||
import { getMomentByName } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
@@ -13,12 +14,15 @@ import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import { useDataLoading } from '@/hooks/useDataLoading'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间详情',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -42,16 +46,17 @@ const siteName = computed(() => {
|
||||
})
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const queryName = ref('')
|
||||
const moment = ref<(IMoment & {
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
|
||||
/** 瞬间卡片(medium 按类型拆为 images/videos/audios + 正文 tag 清理) */
|
||||
type MomentCard = IMoment & {
|
||||
images?: { type?: string, url: string }[]
|
||||
videos?: { id?: string, url: string }[]
|
||||
audios?: { type?: string, url: string }[]
|
||||
spec: IMoment['spec'] & { newHtml?: string }
|
||||
}) | null>(null)
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
}
|
||||
|
||||
/** 移除 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString: string): string {
|
||||
@@ -59,56 +64,63 @@ function removeTagLinksCompletely(htmlString: string): string {
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
|
||||
function tagColor(): string {
|
||||
if (!calcUseTagRandomColor.value)
|
||||
return 'blue'
|
||||
return randomTagColor()
|
||||
/** 瞬间详情 → 展示卡片(作者兜底 + medium 拆分 + tag 清理) */
|
||||
function buildMomentCard(res: IMoment): MomentCard {
|
||||
const medium = (res.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = res.owner
|
||||
return {
|
||||
...res,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...res.spec,
|
||||
newHtml: removeTagLinksCompletely(res.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(time?: string): string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
loading.value = 'loading'
|
||||
try {
|
||||
/* ---------------- 数据加载(useDataLoading 试点:状态由 hook 接管) ---------------- */
|
||||
const { data: moment, status, run: loadMoment } = useDataLoading(
|
||||
async (): Promise<MomentCard> => {
|
||||
const res = await getMomentByName(queryName.value)
|
||||
uni.setNavigationBarTitle({ title: '瞬间详情' })
|
||||
return buildMomentCard(res.data)
|
||||
},
|
||||
{
|
||||
onSuccess: (card) => {
|
||||
nextTick(() => {
|
||||
createVideoContexts(card.videos || [])
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
uni.setNavigationBarTitle({ title: '瞬间详情' })
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const medium = (res.data.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = res.data.owner
|
||||
const tempResult = {
|
||||
...res.data,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...res.data.spec,
|
||||
newHtml: removeTagLinksCompletely(res.data.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
moment.value = tempResult
|
||||
loading.value = 'success'
|
||||
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
|
||||
const calcTagColors = computed(() => {
|
||||
const tags = moment.value?.spec.tags || []
|
||||
return tags.map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f'))
|
||||
})
|
||||
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempResult.videos || [])
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取瞬间详情失败', err)
|
||||
loading.value = 'error'
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
/** 刊头大字日期(如 09月03日) */
|
||||
const calcMastheadDate = computed(() => {
|
||||
const time = moment.value?.spec.releaseTime
|
||||
return time ? formatTimeUtil({ d: time, f: 'MM月dd日' }) : ''
|
||||
})
|
||||
|
||||
/** 刊头辅助信息(如 2026年 · 星期四) */
|
||||
const calcMastheadMeta = computed(() => {
|
||||
const time = moment.value?.spec.releaseTime
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年 · 星期w' }) : ''
|
||||
})
|
||||
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(videos: { id?: string }[]) {
|
||||
@@ -165,13 +177,14 @@ function handleToTopPage(duration = 500) {
|
||||
onLoad((options) => {
|
||||
uni.setNavigationBarTitle({ title: '瞬间加载中...' })
|
||||
queryName.value = options?.name || ''
|
||||
handleGetData()
|
||||
loadMoment()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
onPullDownRefresh(async () => {
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
handleGetData()
|
||||
await loadMoment()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onShareAppMessage(() => ({
|
||||
@@ -186,131 +199,139 @@ onShareTimeline(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafd;">
|
||||
<view v-if="loading !== 'success'" class="loading-wrap h-screen bg-white px-6">
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</view>
|
||||
<view class="app-page box-border min-h-screen w-screen bg-page px-4 pb-8 pt-4">
|
||||
<!-- 状态区(加载中/失败可重试/空) -->
|
||||
<uh-data-loading
|
||||
v-if="status !== 'success'"
|
||||
:loading-status="status"
|
||||
min-height="60vh"
|
||||
error-text="瞬间内容加载失败"
|
||||
empty-text="瞬间不存在或已被删除"
|
||||
@refresh="loadMoment"
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<view v-if="moment" class="moment-card flex flex-col gap-6 p-6">
|
||||
<!-- 用户信息 -->
|
||||
<view class="card flex items-center rounded-xl bg-white p-6 shadow-sm">
|
||||
<image class="avatar h-[80rpx] w-[80rpx] shrink-0 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="nickname ml-3">
|
||||
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="release-time mt-1.5 text-[24rpx] text-[#666]">
|
||||
{{ formatTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
<!-- 互动数据(点赞/评论) -->
|
||||
<view v-if="moment.stats && ((moment.stats.totalComment ?? 0) > 0 || (moment.stats.upvote ?? 0) > 0)" class="stats mt-1.5 flex items-center gap-6 text-[24rpx] text-[#8a919e]">
|
||||
<view class="flex items-center gap-1">
|
||||
<wd-icon name="heart" size="13px" color="#f08585" />
|
||||
<text>{{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-1">
|
||||
<wd-icon name="message" size="13px" color="#9aa3b2" />
|
||||
<text>{{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 瞬间内页(日记式:大字日期刊头 + 阅读正文 + 媒体 + 互动脚注) -->
|
||||
<view v-else-if="moment" class="moment-card uh-shadow-xs overflow-hidden rounded-[28rpx] bg-white">
|
||||
<!-- 刊头:大字日期 -->
|
||||
<view v-if="moment.spec.releaseTime" class="px-7 pt-7">
|
||||
<view class="text-[56rpx] text-gray-900 font-bold leading-tight">
|
||||
{{ calcMastheadDate }}
|
||||
</view>
|
||||
<view class="mt-1 text-[24rpx] text-gray-400">
|
||||
{{ calcMastheadMeta }}
|
||||
</view>
|
||||
<view class="mt-4 h-[8rpx] w-[96rpx] rounded-full bg-secondary" />
|
||||
</view>
|
||||
|
||||
<!-- 作者 -->
|
||||
<view class="flex items-center px-7 pt-6">
|
||||
<image class="h-[80rpx] w-[80rpx] shrink-0 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="ml-3 flex flex-col">
|
||||
<view class="text-[30rpx] text-[#5c7c0f] font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="mt-0.5 text-[22rpx] text-gray-400">
|
||||
记录了这美好的一刻
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 标签 -->
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
|
||||
<text class="tags-label text-[26rpx] text-[#606266]">标签列表:</text>
|
||||
<text v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="tag mr-4 text-[24rpx]" :style="{ color: tagColor() }">
|
||||
{{ tag }}
|
||||
</text>
|
||||
<!-- 正文 -->
|
||||
<view class="content px-7 pt-5">
|
||||
<mp-html
|
||||
class="evan-markdown"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
:loading-img="markdownConfig.loadingGif"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
:content="moment.spec.newHtml || ''"
|
||||
:markdown="true"
|
||||
:show-line-number="true"
|
||||
:show-language-name="true"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 图片附件 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0" class="px-7 pt-6">
|
||||
<view class="images flex flex-wrap items-start">
|
||||
<view
|
||||
v-for="(image, mediumIndex) in moment.images"
|
||||
:key="mediumIndex"
|
||||
class="image-item box-border p-1.5"
|
||||
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')"
|
||||
>
|
||||
<image
|
||||
mode="aspectFill"
|
||||
class="image-src h-full w-full rounded-lg"
|
||||
:src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容 -->
|
||||
<view class="card overflow-hidden rounded-xl bg-white p-6 shadow-sm">
|
||||
<mp-html
|
||||
class="evan-markdown"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
:loading-img="markdownConfig.loadingGif"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
:content="moment.spec.newHtml || ''"
|
||||
:markdown="true"
|
||||
:show-line-number="true"
|
||||
:show-language-name="true"
|
||||
copy-by-long-press
|
||||
<!-- 音频附件 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0" class="px-7 pt-6">
|
||||
<view class="audio-list flex flex-col gap-3">
|
||||
<uh-audio-player
|
||||
v-for="audio in moment.audios"
|
||||
:key="audio.url"
|
||||
:src="audio.url"
|
||||
:poster="bloggerInfo.avatar"
|
||||
:name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 图片附件 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
|
||||
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
|
||||
图片附件:
|
||||
</view>
|
||||
<view class="images flex flex-wrap items-start pt-6" :class="`images-${moment.images.length}`">
|
||||
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex" class="image-item box-border p-1.5" :class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
|
||||
<image
|
||||
mode="aspectFill"
|
||||
class="image-src h-full w-full rounded-lg"
|
||||
:src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 音频附件 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
|
||||
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
|
||||
音频附件:
|
||||
</view>
|
||||
<view class="audio-list flex flex-col gap-3 pt-3">
|
||||
<uh-audio-player
|
||||
v-for="audio in moment.audios"
|
||||
:key="audio.url"
|
||||
:src="audio.url"
|
||||
:poster="bloggerInfo.avatar"
|
||||
:name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 视频附件 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
|
||||
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
|
||||
视频附件:
|
||||
</view>
|
||||
<view class="video-list mt-6 w-full flex flex-col gap-3">
|
||||
<video
|
||||
v-for="(video, index) in moment.videos"
|
||||
:id="`video_${video.id}`"
|
||||
:key="index"
|
||||
class="video-src h-[400rpx] w-full rounded-xl"
|
||||
:src="video.url"
|
||||
:show-mute-btn="true"
|
||||
:controls="true"
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
@play="onVideoPlay(video.id || '')"
|
||||
@pause="onVideoPause(video.id || '')"
|
||||
@ended="onVideoEnded"
|
||||
/>
|
||||
</view>
|
||||
<!-- 视频附件 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0" class="px-7 pt-6">
|
||||
<view class="video-list w-full flex flex-col gap-3">
|
||||
<video
|
||||
v-for="(video, index) in moment.videos"
|
||||
:id="`video_${video.id}`"
|
||||
:key="index"
|
||||
class="video-src h-[400rpx] w-full rounded-xl"
|
||||
:src="video.url"
|
||||
:show-mute-btn="true"
|
||||
:controls="true"
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
@play="onVideoPlay(video.id || '')"
|
||||
@pause="onVideoPause(video.id || '')"
|
||||
@ended="onVideoEnded"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
<!-- 标签 -->
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-2 px-7 pt-6">
|
||||
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="rounded-full bg-black/5 px-3 py-1 text-[22rpx] font-bold" :style="{ color: calcTagColors[tagIndex] }">
|
||||
# {{ tag }}
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 互动脚注(点赞/评论) -->
|
||||
<view v-if="moment.stats" class="mx-7 mt-6 flex items-center justify-end gap-5 border-t border-black/5 py-4 text-[24rpx] text-gray-400">
|
||||
<view class="flex items-center gap-1">
|
||||
<wd-icon name="heart" size="13px" color="#f08585" />
|
||||
<text>{{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-1">
|
||||
<wd-icon name="message" size="13px" color="#9aa3b2" />
|
||||
<text>{{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="h-7" />
|
||||
</view>
|
||||
|
||||
<!-- 回顶 -->
|
||||
<view class="to-top-btn uh-shadow-xs fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-page {
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 瞬间详情页(源自旧项目 pagesA/moment-detail,新建复刻)
|
||||
* 展示瞬间内容(mp-html) + 图片/音频/视频附件
|
||||
* 试点:useDataLoading 状态机接管加载四态 + 壁纸光斑玻璃卡设计
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { getMomentByName } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import { useDataLoading } from '@/hooks/useDataLoading'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间详情',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色,壁纸光斑由页面内提供
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
}
|
||||
})
|
||||
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
|
||||
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
|
||||
const siteName = computed(() => {
|
||||
const appInfo = haloConfigs.value.appConfig?.appInfo as { name?: string } | undefined
|
||||
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
|
||||
})
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const queryName = ref('')
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
|
||||
/** 瞬间卡片(medium 按类型拆为 images/videos/audios + 正文 tag 清理) */
|
||||
type MomentCard = IMoment & {
|
||||
images?: { type?: string, url: string }[]
|
||||
videos?: { id?: string, url: string }[]
|
||||
audios?: { type?: string, url: string }[]
|
||||
spec: IMoment['spec'] & { newHtml?: string }
|
||||
}
|
||||
|
||||
/** 移除 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString: string): string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
|
||||
/** 瞬间详情 → 展示卡片(作者兜底 + medium 拆分 + tag 清理) */
|
||||
function buildMomentCard(res: IMoment): MomentCard {
|
||||
const medium = (res.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = res.owner
|
||||
return {
|
||||
...res,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...res.spec,
|
||||
newHtml: removeTagLinksCompletely(res.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载(useDataLoading 试点:状态由 hook 接管) ---------------- */
|
||||
const { data: moment, status, run: loadMoment } = useDataLoading(
|
||||
async (): Promise<MomentCard> => {
|
||||
const res = await getMomentByName(queryName.value)
|
||||
uni.setNavigationBarTitle({ title: '瞬间详情' })
|
||||
return buildMomentCard(res.data)
|
||||
},
|
||||
{
|
||||
onSuccess: (card) => {
|
||||
nextTick(() => {
|
||||
createVideoContexts(card.videos || [])
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
uni.setNavigationBarTitle({ title: '瞬间详情' })
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
|
||||
const calcTagColors = computed(() => {
|
||||
const tags = moment.value?.spec.tags || []
|
||||
return tags.map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f'))
|
||||
})
|
||||
|
||||
function formatTime(time?: string): string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(videos: { id?: string }[]) {
|
||||
stopAllVideos()
|
||||
videos.forEach((item) => {
|
||||
if (item.id) {
|
||||
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stopAllVideos(excludesVideoId: string | null = null) {
|
||||
Object.keys(videoContexts.value).forEach((videoId) => {
|
||||
if (!excludesVideoId || excludesVideoId !== videoId) {
|
||||
videoContexts.value[videoId]?.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVideoPlay(videoId: string) {
|
||||
currentVideoId.value = videoId
|
||||
stopAllVideos(videoId)
|
||||
}
|
||||
|
||||
function onVideoPause(videoId: string) {
|
||||
if (currentVideoId.value === videoId) {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoEnded() {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handlePreview(index: number, list: { url: string }[]) {
|
||||
uni.previewImage({
|
||||
current: index,
|
||||
urls: list.map(item => item.url),
|
||||
})
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad((options) => {
|
||||
uni.setNavigationBarTitle({ title: '瞬间加载中...' })
|
||||
queryName.value = options?.name || ''
|
||||
loadMoment()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
await loadMoment()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onShareAppMessage(() => ({
|
||||
path: `/pages-blog/moment-detail/moment-detail?name=${moment.value?.metadata.name}`,
|
||||
title: moment.value?.owner?.displayName || '',
|
||||
}))
|
||||
|
||||
onShareTimeline(() => ({
|
||||
title: moment.value?.owner?.displayName || '',
|
||||
query: moment.value ? `name=${moment.value.metadata.name}` : '',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page relative box-border min-h-screen w-screen bg-page pb-8">
|
||||
<!-- 壁纸(柔和光斑为卡片毛玻璃取色,固定不随滚动) -->
|
||||
<view class="pointer-events-none fixed inset-0 z-0 overflow-hidden">
|
||||
<view class="absolute h-[420rpx] w-[420rpx] rounded-full bg-[rgba(103,164,242,0.16)] -left-[120rpx] -top-[60rpx]" />
|
||||
<view class="absolute top-[240rpx] h-[360rpx] w-[360rpx] rounded-full bg-[rgba(244,143,177,0.15)] -right-[130rpx]" />
|
||||
<view class="absolute top-[760rpx] h-[400rpx] w-[400rpx] rounded-full bg-[rgba(179,157,219,0.14)] -left-[150rpx]" />
|
||||
<view class="absolute top-[1220rpx] h-[380rpx] w-[380rpx] rounded-full bg-[rgba(185,228,36,0.15)] -right-[120rpx]" />
|
||||
</view>
|
||||
|
||||
<!-- 状态区(加载中/失败可重试/空) -->
|
||||
<uh-data-loading
|
||||
v-if="status !== 'success'"
|
||||
class="relative z-1"
|
||||
:loading-status="status"
|
||||
min-height="60vh"
|
||||
error-text="瞬间内容加载失败"
|
||||
empty-text="瞬间不存在或已被删除"
|
||||
@refresh="loadMoment"
|
||||
/>
|
||||
|
||||
<!-- 瞬间内容(悬浮玻璃卡) -->
|
||||
<view v-else-if="moment" class="moment-card uh-global-card-glass relative z-1 mx-4 mt-5 flex flex-col rounded-[32rpx] p-5">
|
||||
<!-- 用户区 -->
|
||||
<view class="user flex items-center gap-3">
|
||||
<image class="h-[96rpx] w-[96rpx] shrink-0 border-4 border-white/90 rounded-full shadow-lg" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="nickname min-w-0 flex flex-1 flex-col">
|
||||
<view class="nickname-text text-[32rpx] text-gray-900 font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="release-time mt-1 text-[24rpx] text-gray-400">
|
||||
{{ formatTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- 互动数据(点赞/评论) -->
|
||||
<view v-if="moment.stats && ((moment.stats.totalComment ?? 0) > 0 || (moment.stats.upvote ?? 0) > 0)" class="stats flex shrink-0 items-center gap-2">
|
||||
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
|
||||
<wd-icon name="heart" size="12px" color="#f08585" />
|
||||
<text>{{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
|
||||
<wd-icon name="message" size="12px" color="#7f8ea3" />
|
||||
<text>{{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 标签 -->
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags mt-4 flex flex-wrap gap-2">
|
||||
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="rounded-full bg-black/5 px-3 py-1 text-[22rpx] font-bold" :style="{ color: calcTagColors[tagIndex] }">
|
||||
# {{ tag }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容 -->
|
||||
<view class="content mt-4">
|
||||
<mp-html
|
||||
class="evan-markdown"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
:loading-img="markdownConfig.loadingGif"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
:content="moment.spec.newHtml || ''"
|
||||
:markdown="true"
|
||||
:show-line-number="true"
|
||||
:show-language-name="true"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 图片附件 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0" class="mt-5">
|
||||
<view class="mb-3 flex items-center gap-2">
|
||||
<view class="h-[24rpx] w-[6rpx] rounded-full bg-secondary" />
|
||||
<text class="text-[26rpx] text-gray-500 font-bold">图片附件</text>
|
||||
</view>
|
||||
<view class="images flex flex-wrap items-start">
|
||||
<view
|
||||
v-for="(image, mediumIndex) in moment.images"
|
||||
:key="mediumIndex"
|
||||
class="image-item box-border p-1.5"
|
||||
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')"
|
||||
>
|
||||
<image
|
||||
mode="aspectFill"
|
||||
class="image-src h-full w-full rounded-lg"
|
||||
:src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 音频附件 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0" class="mt-5">
|
||||
<view class="mb-3 flex items-center gap-2">
|
||||
<view class="h-[24rpx] w-[6rpx] rounded-full bg-secondary" />
|
||||
<text class="text-[26rpx] text-gray-500 font-bold">音频附件</text>
|
||||
</view>
|
||||
<view class="audio-list flex flex-col gap-3">
|
||||
<uh-audio-player
|
||||
v-for="audio in moment.audios"
|
||||
:key="audio.url"
|
||||
:src="audio.url"
|
||||
:poster="bloggerInfo.avatar"
|
||||
:name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 视频附件 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0" class="mt-5">
|
||||
<view class="mb-3 flex items-center gap-2">
|
||||
<view class="h-[24rpx] w-[6rpx] rounded-full bg-secondary" />
|
||||
<text class="text-[26rpx] text-gray-500 font-bold">视频附件</text>
|
||||
</view>
|
||||
<view class="video-list w-full flex flex-col gap-3">
|
||||
<video
|
||||
v-for="(video, index) in moment.videos"
|
||||
:id="`video_${video.id}`"
|
||||
:key="index"
|
||||
class="video-src h-[400rpx] w-full rounded-xl"
|
||||
:src="video.url"
|
||||
:show-mute-btn="true"
|
||||
:controls="true"
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
@play="onVideoPlay(video.id || '')"
|
||||
@pause="onVideoPause(video.id || '')"
|
||||
@ended="onVideoEnded"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 回顶 -->
|
||||
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 公告详情页(plugin-uni-halo 通知公告,2026-09-03 客户端接入)
|
||||
* 公开 GET /notices/{name} 返回完整 Notice extension(metadata+spec,spec 内嵌
|
||||
* typeDisplayName/typeColor);正文 content 为富文本 HTML,mp-html 渲染。
|
||||
* 不存在/删除中返回 404 → 空态提示。UIUX 见 .docs/notice-module-client-design.md
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getNoticeDetail } from '@/api/uni-halo'
|
||||
import { checkIsUrl } from '@/utils/url'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { INoticeDetail } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '公告详情',
|
||||
},
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
const detail = ref<INoticeDetail | null>(null)
|
||||
|
||||
const spec = computed(() => detail.value?.spec)
|
||||
const content = computed(() => spec.value?.content || '')
|
||||
const title = computed(() => spec.value?.title || '')
|
||||
const typeColor = computed(() => spec.value?.typeColor || '')
|
||||
const typeDisplayName = computed(() => spec.value?.typeDisplayName || '')
|
||||
const cover = computed(() => spec.value?.cover || '')
|
||||
const publishTime = computed(() => formatDate(spec.value?.publishTime))
|
||||
const hasLink = computed(() => !!spec.value?.link && checkIsUrl(spec.value?.link || ''))
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value)
|
||||
return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack()
|
||||
}
|
||||
else {
|
||||
uni.switchTab({ url: '/pages/tabbar/home/home' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 外链 → 复用 website 承载页 */
|
||||
function handleToExternal() {
|
||||
if (!spec.value?.link)
|
||||
return
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/website/website?data=${JSON.stringify({
|
||||
title: title.value || '公告原文',
|
||||
url: encodeURIComponent(spec.value.link),
|
||||
})}`,
|
||||
})
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
const name = options?.name || ''
|
||||
if (!name) {
|
||||
notFound.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getNoticeDetail(name)
|
||||
detail.value = res.data || null
|
||||
if (!detail.value?.spec) {
|
||||
notFound.value = true
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('公告详情加载失败', err)
|
||||
const code = (err as { code?: number }).code
|
||||
notFound.value = code === 404
|
||||
if (code !== 404) {
|
||||
uni.showToast({ icon: 'none', title: '公告加载失败' })
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="notice-detail min-h-screen w-screen bg-white pb-12">
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading" class="flex flex-col items-center justify-center py-40">
|
||||
<text class="text-[26rpx] text-[#999]">
|
||||
加载中...
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 不存在/已下线 -->
|
||||
<view v-else-if="notFound" class="flex flex-col items-center justify-center py-40">
|
||||
<text class="text-[60rpx]">
|
||||
🕳️
|
||||
</text>
|
||||
<text class="mt-6 text-[26rpx] text-[#999]">
|
||||
公告不存在或已下线
|
||||
</text>
|
||||
<view class="mt-10">
|
||||
<wd-button size="small" type="primary" plain @click="handleBack">
|
||||
返回
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正文 -->
|
||||
<view v-else class="px-6 py-6">
|
||||
<image
|
||||
v-if="cover"
|
||||
class="mb-5 h-[320rpx] w-full rounded-xl"
|
||||
:src="cover"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="text-[36rpx] font-bold leading-snug text-[#222]">
|
||||
{{ title }}
|
||||
</view>
|
||||
|
||||
<view class="mt-3 flex items-center gap-2">
|
||||
<view
|
||||
v-if="typeDisplayName"
|
||||
class="rounded px-2 py-0.5 text-[20rpx]"
|
||||
:style="{
|
||||
color: typeColor || '#f83856',
|
||||
backgroundColor: typeColor ? `${typeColor}1a` : '#fdeef1',
|
||||
}"
|
||||
>
|
||||
{{ typeDisplayName }}
|
||||
</view>
|
||||
<text class="text-[22rpx] text-[#bbb]">
|
||||
{{ publishTime }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mt-4 border-t border-[#f0f0f0] pt-5">
|
||||
<mp-html
|
||||
:content="content"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
:show-line-number="false"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="hasLink" class="mt-10">
|
||||
<wd-button type="primary" block round @click="handleToExternal">
|
||||
查看原文 ↗
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notice-detail {
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 公告中心·列表页(plugin-uni-halo 通知公告,2026-09-03 客户端接入)
|
||||
* 公开接口 GET /notices 仅分页(脱敏不含 content);公告量小,页面一次拉取
|
||||
* size=100 后本地做「类型筛选 + 排序(最新/最早/按类型分组)」,触底续拉下一页。
|
||||
* UIUX 见 .docs/notice-module-client-design.md
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getNotices } from '@/api/uni-halo'
|
||||
import type { INoticeListVo } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '公告中心',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
const loading = ref(false)
|
||||
const allItems = ref<INoticeListVo[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const activeType = ref('')
|
||||
type SortKey = 'latest' | 'earliest' | 'group'
|
||||
const sortKey = ref<SortKey>('latest')
|
||||
|
||||
const SORT_OPTIONS: Array<{ id: SortKey, label: string }> = [
|
||||
{ id: 'latest', label: '最新在前' },
|
||||
{ id: 'earliest', label: '最早在前' },
|
||||
{ id: 'group', label: '按类型分组' },
|
||||
]
|
||||
|
||||
/* ---------------- 类型 chips(从已加载列表聚合) ---------------- */
|
||||
const typeOptions = computed(() => {
|
||||
const map = new Map<string, { typeName: string, typeDisplayName: string, typeColor: string }>()
|
||||
for (const item of allItems.value) {
|
||||
const key = item.typeName || ''
|
||||
const display = item.typeDisplayName || '未分类'
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { typeName: key, typeDisplayName: display, typeColor: item.typeColor || '' })
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
/* ---------------- 时间与排序 ---------------- */
|
||||
function formatDate(value?: string): string {
|
||||
if (!value)
|
||||
return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
function timeOf(item: INoticeListVo): number {
|
||||
const time = item.publishTime ? new Date(item.publishTime).getTime() : NaN
|
||||
return Number.isNaN(time) ? 0 : time
|
||||
}
|
||||
|
||||
/** 展示列表:先按类型筛选,再按排序键排序(时间倒序/正序/按类型分组) */
|
||||
const displayList = computed(() => {
|
||||
let list = allItems.value
|
||||
if (activeType.value !== '') {
|
||||
list = list.filter(item => (item.typeName || '') === activeType.value)
|
||||
}
|
||||
if (sortKey.value === 'latest') {
|
||||
list = [...list].sort((a, b) => timeOf(b) - timeOf(a))
|
||||
}
|
||||
else if (sortKey.value === 'earliest') {
|
||||
list = [...list].sort((a, b) => timeOf(a) - timeOf(b))
|
||||
}
|
||||
else {
|
||||
// 按类型分组:未分类排最后,组内按发布时间倒序
|
||||
const groupOrder = new Map<string, number>()
|
||||
for (const item of allItems.value) {
|
||||
const key = item.typeName || ''
|
||||
if (!groupOrder.has(key)) {
|
||||
groupOrder.set(key, groupOrder.size)
|
||||
}
|
||||
}
|
||||
list = [...list].sort((a, b) => {
|
||||
const ka = a.typeName || ''
|
||||
const kb = b.typeName || ''
|
||||
const ga = groupOrder.get(ka) ?? Number.MAX_SAFE_INTEGER
|
||||
const gb = groupOrder.get(kb) ?? Number.MAX_SAFE_INTEGER
|
||||
if (ga !== gb)
|
||||
return ga - gb
|
||||
return timeOf(b) - timeOf(a)
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function loadNotices(reset: boolean) {
|
||||
if (loading.value)
|
||||
return
|
||||
loading.value = true
|
||||
try {
|
||||
const target = reset ? 1 : page.value + 1
|
||||
const res = await getNotices({ page: target, size: PAGE_SIZE })
|
||||
const body = res.data
|
||||
const items = body?.items || []
|
||||
if (reset) {
|
||||
allItems.value = items
|
||||
page.value = 1
|
||||
}
|
||||
else {
|
||||
allItems.value = [...allItems.value, ...items]
|
||||
page.value = target
|
||||
}
|
||||
total.value = body?.total ?? allItems.value.length
|
||||
}
|
||||
catch (err) {
|
||||
console.error('公告列表加载失败', err)
|
||||
uni.showToast({ icon: 'none', title: '公告加载失败' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loading.value || allItems.value.length >= total.value)
|
||||
return
|
||||
loadNotices(false)
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
loadNotices(true)
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
loadMore()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await loadNotices(true)
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
/* ---------------- 跳转 ---------------- */
|
||||
function handleToDetail(item: INoticeListVo) {
|
||||
if (!item.name)
|
||||
return
|
||||
uni.navigateTo({ url: `/pages-blog/notice/detail?name=${item.name}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="notice-page min-h-screen w-screen bg-[#f7f8fa]">
|
||||
<!-- 类型筛选(横滑 chips) -->
|
||||
<scroll-view scroll-x class="type-bar sticky top-0 z-10 w-full bg-white px-3 py-2" :show-scrollbar="false">
|
||||
<view class="flex items-center gap-2 whitespace-nowrap">
|
||||
<view
|
||||
class="rounded-full px-4 py-1.5 text-[24rpx]"
|
||||
:class="activeType === '' ? 'bg-[#f83856] text-white' : 'bg-[#f0f0f0] text-[#666]'"
|
||||
@click="activeType = ''"
|
||||
>
|
||||
全部
|
||||
</view>
|
||||
<view
|
||||
v-for="type in typeOptions"
|
||||
:key="type.typeName || '__none__'"
|
||||
class="flex items-center gap-1 rounded-full px-4 py-1.5 text-[24rpx]"
|
||||
:class="activeType === type.typeName ? 'bg-[#f83856] text-white' : 'bg-[#f0f0f0] text-[#666]'"
|
||||
@click="activeType = activeType === type.typeName ? '' : type.typeName"
|
||||
>
|
||||
<view v-if="type.typeColor" class="h-2 w-2 rounded-full" :style="{ backgroundColor: type.typeColor }" />
|
||||
<text>{{ type.typeDisplayName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 排序 -->
|
||||
<view class="sort-bar flex items-center gap-2 px-4 py-2">
|
||||
<view
|
||||
v-for="opt in SORT_OPTIONS"
|
||||
:key="opt.id"
|
||||
class="rounded-full px-3 py-1 text-[22rpx]"
|
||||
:class="sortKey === opt.id ? 'bg-[#fdeef1] text-[#f83856]' : 'text-[#999]'"
|
||||
@click="sortKey = opt.id"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表 -->
|
||||
<view v-if="displayList.length > 0" class="px-3 pb-6">
|
||||
<view
|
||||
v-for="item in displayList"
|
||||
:key="item.name"
|
||||
class="mb-3 flex overflow-hidden rounded-xl bg-white p-4"
|
||||
@click="handleToDetail(item)"
|
||||
>
|
||||
<image
|
||||
v-if="item.cover"
|
||||
class="mr-3 h-[140rpx] w-[180rpx] shrink-0 rounded-lg"
|
||||
:src="item.cover"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="text-[28rpx] font-bold leading-snug text-[#333] line-clamp-2">
|
||||
{{ item.title }}
|
||||
</view>
|
||||
<view v-if="item.summary" class="mt-1 text-[24rpx] leading-relaxed text-[#888] line-clamp-2">
|
||||
{{ item.summary }}
|
||||
</view>
|
||||
<view class="mt-2 flex items-center gap-2">
|
||||
<view
|
||||
v-if="item.typeDisplayName"
|
||||
class="rounded px-1.5 py-0.5 text-[20rpx]"
|
||||
:style="{
|
||||
color: item.typeColor || '#f83856',
|
||||
backgroundColor: item.typeColor ? `${item.typeColor}1a` : '#fdeef1',
|
||||
}"
|
||||
>
|
||||
{{ item.typeDisplayName }}
|
||||
</view>
|
||||
<text class="text-[22rpx] text-[#bbb]">
|
||||
{{ formatDate(item.publishTime) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="py-6 text-center text-[24rpx] text-[#bbb]">
|
||||
加载中...
|
||||
</view>
|
||||
<view v-else-if="allItems.length >= total.value" class="py-6 text-center text-[24rpx] text-[#bbb]">
|
||||
没有更多了
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view v-else class="flex flex-col items-center justify-center py-32">
|
||||
<text class="text-[40rpx]">📢</text>
|
||||
<text class="mt-4 text-[26rpx] text-[#999]">
|
||||
{{ loading ? '加载中...' : '暂无公告' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.type-bar {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
* 功能:关键词搜索文章/瞬间,结果列表展示
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getPostListByKeyword } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
@@ -43,23 +43,15 @@ const dataList = ref<{
|
||||
updateTimestamp?: string
|
||||
}[]>([])
|
||||
|
||||
/* ---------------- 动画(对应旧版 mixin calcAniWait/fnResetSetAniWaitIndex) ---------------- */
|
||||
const aniWaitIndex = ref(0)
|
||||
|
||||
function resetAniWaitIndex() {
|
||||
aniWaitIndex.value = 0
|
||||
}
|
||||
|
||||
/** 计算列表项动画等待(每 10 项重置一轮,每项递增 50ms) */
|
||||
function calcAniWait(index: number): number {
|
||||
if ((index + 1) % 10 === 0) {
|
||||
aniWaitIndex.value = 1
|
||||
}
|
||||
else {
|
||||
aniWaitIndex.value += 1
|
||||
}
|
||||
return aniWaitIndex.value * 50
|
||||
}
|
||||
/* ---------------- 动画(对应旧版 mixin calcAniWait) ---------------- */
|
||||
/** 预计算列表项入场延迟(每 10 项重置一轮,每项递增 50ms);必须在渲染外算好,渲染中修改响应式状态会导致递归更新 */
|
||||
const calcAniDelays = computed(() => {
|
||||
let wait = 0
|
||||
return dataList.value.map((_, index) => {
|
||||
wait = (index + 1) % 10 === 0 ? 1 : wait + 1
|
||||
return wait * 50
|
||||
})
|
||||
})
|
||||
|
||||
/* ---------------- 搜索 ---------------- */
|
||||
async function handleGetData() {
|
||||
@@ -83,7 +75,6 @@ async function handleGetData() {
|
||||
}
|
||||
|
||||
function handleOnSearch() {
|
||||
resetAniWaitIndex()
|
||||
if (!queryParams.value.keyword) {
|
||||
dataList.value = []
|
||||
loading.value = 'success'
|
||||
@@ -131,7 +122,6 @@ function handleToTopPage(duration = 500) {
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
resetAniWaitIndex()
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
@@ -146,10 +136,6 @@ onLoad(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
resetAniWaitIndex()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
@@ -160,7 +146,7 @@ onPullDownRefresh(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafd;">
|
||||
<view class="app-page min-h-screen w-screen flex flex-col bg-page pb-6">
|
||||
<uh-plugin-unavailable
|
||||
v-if="!uniHaloPluginAvailable"
|
||||
:plugin-id="uniHaloPluginId"
|
||||
@@ -168,36 +154,36 @@ onPullDownRefresh(() => {
|
||||
@on-refresh="handleOnSearch"
|
||||
/>
|
||||
<template v-else>
|
||||
<!-- 顶部搜索框 -->
|
||||
<view class="search-bar fixed inset-x-0 top-0 z-6 bg-white px-3 py-2 shadow-sm">
|
||||
<view class="search-input h-[68rpx] flex items-center gap-3 rounded-[34rpx] bg-[#f5f5f5] px-6">
|
||||
<wd-icon name="search" size="16px" color="#999" />
|
||||
<!-- 顶部搜索框(玻璃吸顶,呼应首页搜索条) -->
|
||||
<view class="search-bar uh-global-card-glass sticky top-0 z-10 px-3 py-2">
|
||||
<view class="search-input h-[72rpx] flex items-center gap-3 rounded-full bg-[#f6f3ee] px-5">
|
||||
<wd-icon name="search" size="16px" color="#a8a294" />
|
||||
<input
|
||||
v-model="queryParams.keyword"
|
||||
class="search-field flex-1 text-[26rpx]"
|
||||
placeholder="搜索内容..."
|
||||
class="search-field flex-1 text-[26rpx] text-gray-900"
|
||||
placeholder="哈喽,想看些什么呢~"
|
||||
placeholder-class="text-gray-400"
|
||||
confirm-type="search"
|
||||
@input="handleOnInput"
|
||||
@confirm="handleOnSearch"
|
||||
>
|
||||
<view v-if="queryParams.keyword" class="clear-btn flex items-center" @click="queryParams.keyword = ''; handleOnSearch()">
|
||||
<wd-icon name="close" size="14px" color="#999" />
|
||||
<wd-icon name="close" size="14px" color="#a8a294" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="h-[100rpx] w-screen" />
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loading === 'loading'" class="loading-wrap p-3">
|
||||
<wd-skeleton :row="4" :animated="true" />
|
||||
</view>
|
||||
<view v-else-if="loading === 'error'" class="h-[60vh] flex items-center justify-center content-empty">
|
||||
<view v-else-if="loading === 'error'" class="min-h-[60vh] flex items-center justify-center content-empty">
|
||||
<wd-empty description="搜索异常" />
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view v-else class="content pt-6">
|
||||
<view v-if="dataList.length === 0" class="h-[60vh] flex items-center justify-center content-empty">
|
||||
<view v-else class="content pt-4">
|
||||
<view v-if="dataList.length === 0" class="min-h-[60vh] flex items-center justify-center content-empty">
|
||||
<wd-empty v-if="!queryParams.keyword" description="请输入关键词搜索" />
|
||||
<wd-empty v-else :description="`未搜到 ${queryParams.keyword} 相关内容`" />
|
||||
</view>
|
||||
@@ -206,15 +192,18 @@ onPullDownRefresh(() => {
|
||||
<view
|
||||
v-for="(item, index) in dataList"
|
||||
:key="index"
|
||||
class="article-card fade-up mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm"
|
||||
:style="{ animationDelay: `${calcAniWait(index)}ms` }"
|
||||
class="article-card fade-up uh-global-card-glass mx-4 mb-4 flex flex-col overflow-hidden rounded-2xl p-4"
|
||||
:style="{ animationDelay: `${calcAniDelays[index]}ms` }"
|
||||
@click="handleToDetail(item)"
|
||||
>
|
||||
<view class="card-head mb-3 flex items-center">
|
||||
<view class="type-tag mr-3 shrink-0 rounded-md px-1.5 py-0.5 text-[22rpx] text-white" :class="isArticle(item) ? 'bg-[#2196f3]' : 'bg-[#4caf50]'">
|
||||
<view
|
||||
class="type-tag mr-3 shrink-0 rounded-md px-1.5 py-0.5 text-[20rpx] leading-none"
|
||||
:class="isArticle(item) ? 'bg-secondary text-[#4d7c0f]' : 'bg-[#e8e3d8] text-gray-600'"
|
||||
>
|
||||
{{ isArticle(item) ? '文章' : '瞬间' }}
|
||||
</view>
|
||||
<text class="card-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[28rpx] text-[#333] font-bold">{{ item.title }}</text>
|
||||
<text class="card-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[28rpx] text-gray-900 font-bold">{{ item.title }}</text>
|
||||
</view>
|
||||
<mp-html
|
||||
class="evan-markdown"
|
||||
@@ -232,12 +221,12 @@ onPullDownRefresh(() => {
|
||||
copy-by-long-press
|
||||
/>
|
||||
<view class="card-foot mt-3 flex items-center">
|
||||
<text class="text-[24rpx] text-[#888]">{{ item.updateTimestamp ? `最近更新:${formatTimeUtil({ d: item.updateTimestamp, f: 'yyyy年MM月dd日 HH点mm分ss秒' })}` : '' }}</text>
|
||||
<text class="text-[24rpx] text-gray-400">{{ item.updateTimestamp ? `最近更新:${formatTimeUtil({ d: item.updateTimestamp, f: 'yyyy年MM月dd日 HH点mm分ss秒' })}` : '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
@@ -246,11 +235,7 @@ onPullDownRefresh(() => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-page {
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
|
||||
/* 列表项入场动画(对应旧版 tm-translate fadeUp) */
|
||||
/* 列表项入场动画(对应旧版 tm-translate fadeUp):@keyframes 无法用原子类表达,保留 scoped 样式 */
|
||||
.fade-up {
|
||||
animation: fade-up 0.4s ease-out both;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 偏好设置页(两层:站点默认 L0 + 本地差异 L1-L,设计见 .docs/config-system-v2-redesign §3-4)
|
||||
* - 每项可「跟随站点默认」(差异为空)或覆盖为具体值;改动即写本地差异(uh_pref_local_v1)即时生效;
|
||||
* - 底部「重置为站点默认」= 清空全部本地差异,回退站长在插件后台配置的默认(未配置则为内置默认)。
|
||||
* - 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title)
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
@@ -204,92 +205,107 @@ function handleResetAll() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page box-border min-h-screen pb-[180rpx]" style="background-color: #fafafd;">
|
||||
<view class="app-page box-border min-h-screen bg-page pb-[200rpx]">
|
||||
<!-- 说明 -->
|
||||
<view class="pref-tip mx-6 mt-6 rounded-xl bg-white px-6 py-4 shadow-sm">
|
||||
<text class="tip-text text-[22rpx] leading-[1.6] text-[#909399]">
|
||||
<view class="pref-tip uh-global-card-glass mx-4 mt-4 flex items-start gap-2 rounded-xl px-4 py-3">
|
||||
<wd-icon name="info" size="28rpx" color="#a8a294" class="mt-0.5 shrink-0" />
|
||||
<text class="tip-text flex-1 text-2xs text-gray-400 leading-[1.6]">
|
||||
你的偏好仅保存在本机。未自定义的项自动跟随站长在插件后台配置的「站点默认」;点击底部「重置为站点默认」可清空全部本地偏好。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 布局设置 -->
|
||||
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
|
||||
<text class="title-text text-[30rpx] text-[#303133] font-bold">布局</text>
|
||||
<text class="title-desc ml-3 text-[22rpx] text-[#999]">应用以及文章列表布局设置</text>
|
||||
</view>
|
||||
<view class="sheet-content">
|
||||
<view
|
||||
v-for="def in layoutPrefs"
|
||||
:key="def.key"
|
||||
class="pick-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7"
|
||||
@click="handleOpenEnum(def)"
|
||||
>
|
||||
<view class="row-left flex flex-col">
|
||||
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
|
||||
<text v-if="isFollowing(def)" class="row-sub mt-1 text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
|
||||
<text v-else class="row-sub mt-1 text-[20rpx] text-[#03a9f4]">已自定义</text>
|
||||
</view>
|
||||
<view class="row-value flex items-center gap-2">
|
||||
<text class="value-text text-[26rpx] text-[#999]">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#999" />
|
||||
<uh-section-title class="mx-4 mb-3 mt-6 text-[30rpx]">
|
||||
布局
|
||||
<template #right>
|
||||
<text class="text-2xs text-gray-400">应用以及文章列表布局设置</text>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
<view class="setting-sheet uh-global-card-glass mx-4 overflow-hidden rounded-2xl">
|
||||
<view
|
||||
v-for="(def, index) in layoutPrefs"
|
||||
:key="def.key"
|
||||
class="pick-row flex items-center justify-between px-4 py-4"
|
||||
:class="index < layoutPrefs.length - 1 ? 'border-b border-black/5' : ''"
|
||||
@click="handleOpenEnum(def)"
|
||||
>
|
||||
<view class="row-left flex flex-col gap-1">
|
||||
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
|
||||
<view class="flex items-center gap-2">
|
||||
<text v-if="isFollowing(def)" class="row-sub text-2xs text-gray-400">跟随站点默认</text>
|
||||
<view v-else class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
|
||||
已自定义
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="row-value flex items-center gap-2">
|
||||
<text class="value-text text-[26rpx] text-gray-400">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能设置 -->
|
||||
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
|
||||
<text class="title-text text-[30rpx] text-[#303133] font-bold">功能</text>
|
||||
<text class="title-desc ml-3 text-[22rpx] text-[#999]">一些常用的功能性设置</text>
|
||||
</view>
|
||||
<view class="sheet-content">
|
||||
<template v-for="def in featurePrefs" :key="def.key">
|
||||
<!-- 布尔开关 -->
|
||||
<view v-if="def.kind === 'bool'" class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-5">
|
||||
<view class="row-left flex flex-col">
|
||||
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
|
||||
<view class="mt-1 flex items-center gap-2">
|
||||
<text v-if="isFollowing(def)" class="row-sub text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
|
||||
<text v-else class="row-sub text-[20rpx] text-[#03a9f4]">已自定义</text>
|
||||
<text
|
||||
v-if="!isFollowing(def)"
|
||||
class="revert-text text-[20rpx] text-[#909399] underline"
|
||||
@click.stop="handleRevert(def.path)"
|
||||
>跟随默认</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-switch :model-value="!!valueOf(def.path)" @change="handleSwitchChange(def, $event)" />
|
||||
</view>
|
||||
<!-- 枚举选择(指示器位置) -->
|
||||
<view v-else class="pick-row flex items-center justify-between px-8 py-5" @click="handleOpenEnum(def)">
|
||||
<view class="row-left flex flex-col">
|
||||
<text class="row-label text-[28rpx] text-[#333]">{{ def.label }}</text>
|
||||
<view class="mt-1 flex items-center gap-2">
|
||||
<text v-if="isFollowing(def)" class="row-sub text-[20rpx] text-[#b2b6bd]">跟随站点默认</text>
|
||||
<text v-else class="row-sub text-[20rpx] text-[#03a9f4]">已自定义</text>
|
||||
<text
|
||||
v-if="!isFollowing(def)"
|
||||
class="revert-text text-[20rpx] text-[#909399] underline"
|
||||
@click.stop="handleRevert(def.path)"
|
||||
>跟随默认</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="row-value flex items-center gap-2">
|
||||
<text class="value-text text-[26rpx] text-[#999]">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#999" />
|
||||
<uh-section-title class="mx-4 mb-3 mt-6 text-[30rpx]">
|
||||
功能
|
||||
<template #right>
|
||||
<text class="text-2xs text-gray-400">一些常用的功能性设置</text>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
<view class="setting-sheet uh-global-card-glass mx-4 overflow-hidden rounded-2xl">
|
||||
<template v-for="(def, index) in featurePrefs" :key="def.key">
|
||||
<!-- 布尔开关 -->
|
||||
<view
|
||||
v-if="def.kind === 'bool'"
|
||||
class="switch-row flex items-center justify-between px-4 py-4"
|
||||
:class="index < featurePrefs.length - 1 ? 'border-b border-black/5' : ''"
|
||||
>
|
||||
<view class="row-left flex flex-col gap-1">
|
||||
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
|
||||
<view class="flex items-center gap-2">
|
||||
<text v-if="isFollowing(def)" class="row-sub text-2xs text-gray-400">跟随站点默认</text>
|
||||
<template v-else>
|
||||
<view class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
|
||||
已自定义
|
||||
</view>
|
||||
<text class="revert-text text-2xs text-gray-400 underline" @click.stop="handleRevert(def.path)">恢复默认</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<wd-switch :model-value="!!valueOf(def.path)" @change="handleSwitchChange(def, $event)" />
|
||||
</view>
|
||||
<!-- 枚举选择(指示器位置) -->
|
||||
<view
|
||||
v-else
|
||||
class="pick-row flex items-center justify-between px-4 py-4"
|
||||
:class="index < featurePrefs.length - 1 ? 'border-b border-black/5' : ''"
|
||||
@click="handleOpenEnum(def)"
|
||||
>
|
||||
<view class="row-left flex flex-col gap-1">
|
||||
<text class="row-label text-[28rpx] text-gray-900 font-bold">{{ def.label }}</text>
|
||||
<view class="flex items-center gap-2">
|
||||
<text v-if="isFollowing(def)" class="row-sub text-2xs text-gray-400">跟随站点默认</text>
|
||||
<template v-else>
|
||||
<view class="rounded-full bg-secondary px-2 py-0.5 text-[20rpx] text-[#4d7c0f] leading-none">
|
||||
已自定义
|
||||
</view>
|
||||
<text class="revert-text text-2xs text-gray-400 underline" @click.stop="handleRevert(def.path)">恢复默认</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<view class="row-value flex items-center gap-2">
|
||||
<text class="value-text text-[26rpx] text-gray-400">{{ enumLabelOf(def, valueOf(def.path)) }}</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 操作区域 -->
|
||||
<view class="btn-bar fixed bottom-0 left-0 box-border w-screen bg-white p-6 shadow-sm">
|
||||
<wd-button type="danger" size="medium" block @click="handleResetAll">
|
||||
重置为站点默认
|
||||
</wd-button>
|
||||
<!-- 底部操作栏(玻璃悬浮 + 底部安全区适配) -->
|
||||
<view class="btn-bar uh-global-card-glass fixed bottom-0 left-0 box-border w-screen px-4 pt-3 pb-safe">
|
||||
<view class="reset-btn h-[84rpx] w-full flex items-center justify-center rounded-full bg-gray-900 active:opacity-80" @click="handleResetAll">
|
||||
<text class="text-[28rpx] text-white font-bold">重置为站点默认</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 枚举选择底部弹层 -->
|
||||
@@ -301,22 +317,22 @@ function handleResetAll() {
|
||||
@close="handleCloseEnum"
|
||||
>
|
||||
<view v-if="enumSheet.def" class="enum-sheet box-border w-full pb-[env(safe-area-inset-bottom)]">
|
||||
<view class="enum-title border-b border-[#f5f5f5] py-6 text-center text-[30rpx] font-bold text-[#303133]">
|
||||
<view class="enum-title py-6 text-center text-[30rpx] text-gray-900 font-bold">
|
||||
{{ enumSheet.def.label }}
|
||||
</view>
|
||||
<view
|
||||
class="enum-item flex items-center justify-between px-8 py-6"
|
||||
:class="isFollowing(enumSheet.def) ? 'text-[#03a9f4]' : 'text-[#333]'"
|
||||
class="enum-item flex items-center justify-between px-6 py-5"
|
||||
:class="isFollowing(enumSheet.def) ? 'bg-secondary text-[#4d7c0f]' : 'text-gray-900'"
|
||||
@click="handleChooseEnum(null)"
|
||||
>
|
||||
<text class="text-[28rpx]">跟随站点默认</text>
|
||||
<wd-icon v-if="isFollowing(enumSheet.def)" name="check" size="16px" color="#03a9f4" />
|
||||
<wd-icon v-if="isFollowing(enumSheet.def)" name="check" size="16px" color="#4d7c0f" />
|
||||
</view>
|
||||
<view
|
||||
v-for="opt in enumSheet.def.options"
|
||||
:key="opt.value"
|
||||
class="enum-item flex items-center justify-between border-t border-[#f5f5f5] px-8 py-6"
|
||||
:class="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def) ? 'text-[#03a9f4]' : 'text-[#333]'"
|
||||
class="enum-item flex items-center justify-between border-t border-[#f0ece2] px-6 py-5"
|
||||
:class="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def) ? 'bg-secondary text-[#4d7c0f]' : 'text-gray-900'"
|
||||
@click="handleChooseEnum(opt.value)"
|
||||
>
|
||||
<text class="text-[28rpx]">{{ opt.label }}</text>
|
||||
@@ -324,18 +340,10 @@ function handleResetAll() {
|
||||
v-if="valueOf(enumSheet.def.path) === opt.value && !isFollowing(enumSheet.def)"
|
||||
name="check"
|
||||
size="16px"
|
||||
color="#03a9f4"
|
||||
color="#4d7c0f"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn-bar {
|
||||
:deep(wd-button) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { DataLoadingStatusEnum } from '@/hooks/useDataLoadingStatus'
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '测试页面',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="w-full h-screen">
|
||||
<uh-data-loading :loading-status="DataLoadingStatusEnum.Empty"></uh-data-loading>
|
||||
</view>
|
||||
</template>
|
||||
@@ -117,7 +117,7 @@ onReachBottom(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
|
||||
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
|
||||
<uh-plugin-unavailable
|
||||
v-if="!uniHaloPluginAvailable"
|
||||
:plugin-id="uniHaloPluginId"
|
||||
@@ -129,7 +129,7 @@ onReachBottom(() => {
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</view>
|
||||
|
||||
<view v-else class="content flex flex-col gap-6 p-3">
|
||||
<view v-else class="content flex flex-col gap-4 p-3">
|
||||
<view v-if="dataList.length === 0" class="min-h-[60vh] flex items-center justify-center content-empty">
|
||||
<wd-empty description="博主还未发布投票~" />
|
||||
</view>
|
||||
@@ -140,20 +140,14 @@ onReachBottom(() => {
|
||||
:vote-name="vote.name"
|
||||
@on-vote-success="handleOnVoteSuccess"
|
||||
/>
|
||||
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
|
||||
<view class="load-text py-5 text-center text-[24rpx] text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-page {
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,9 +26,9 @@ const homePagePath = '/pages/tabbar/home/home'
|
||||
const articleDetailPath = '/pages-blog/article-detail/article-detail'
|
||||
|
||||
// 本地开发快速跳转页面,发布请置为 false
|
||||
const DEV_MODE = false
|
||||
const DEV_MODE = true
|
||||
const DEV_TO_TYPE = 'page' as 'page' | 'tabbar'
|
||||
const DEV_TO_PATH = `${articleDetailPath}?name=01a057b2-3200-74af-8afe-28a054092e82`
|
||||
const DEV_TO_PATH = `/pages-blog/test/test`
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const appConfigStore = useAppConfigStore()
|
||||
@@ -57,11 +57,6 @@ async function getPostIdByQRCode(key: string): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 获取审核模式数据(公开接口 /audit-data,enabled 联动设置页开关) */
|
||||
async function handleAuditMode() {
|
||||
await appConfigStore.fetchAuditData()
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
// 本地开发,快速跳转页面,发布请设置 DEV_MODE = false
|
||||
if (DEV_MODE && DEV_TO_PATH) {
|
||||
@@ -78,10 +73,11 @@ onLoad(async (options) => {
|
||||
if (!(await handleCheckPluginAvailable()))
|
||||
return
|
||||
|
||||
// 获取配置
|
||||
// 获取配置(统一 bootstrap: getConfigs + audit-data + love-config 并行一次;
|
||||
// TTL 内直接返回缓存。设计见 .docs/static-config-unified-fetch-design.md)
|
||||
try {
|
||||
const res = await appConfigStore.fetchConfigs()
|
||||
if (!res) {
|
||||
const { ok } = await appConfigStore.bootstrap()
|
||||
if (!ok) {
|
||||
uni.switchTab({ url: homePagePath })
|
||||
return
|
||||
}
|
||||
@@ -98,10 +94,8 @@ onLoad(async (options) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 审计模式数据(公开接口 /audit-data)
|
||||
await handleAuditMode()
|
||||
|
||||
// 两层偏好合并:应用站点默认(L0)到 setting store(内部合并本地差异,含旧数据迁移)
|
||||
// 两层偏好合并:应用站点默认(L0)到 setting store(内部合并本地差异,含旧数据迁移);
|
||||
// 审核模式数据已随 bootstrap 拉取(auditData/auditModeEnabled 即可用)
|
||||
settingStore.applySiteDefaults(collectSiteDefaults(appConfigStore.configs))
|
||||
|
||||
// 启动页已下线(v2.2 ⑤):直接进首页
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 维护页(plugin-uni-halo 维护模式,2026-09-04 客户端接入)
|
||||
* 数据源:getConfigs additive 顶层 maintenance 键(store.configs;仅 scheduled/active
|
||||
* 时插件下发,键缺失=未维护或已到点自动结束)。logo 复用 appConfig.appInfo.logo
|
||||
* (相对路径经 checkUrl/BASE_API 补全)。双态:scheduled 维护预告(倒计时至 startTime)
|
||||
* / active 维护中(倒计时至 endTime);到点自动重拉判定(服务端状态切换/自动结束)。
|
||||
* 设计见插件 .docs/maintenance-config-design.md §8。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkUrl } from '@/utils/url'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IPublicMaintenance } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '站点维护',
|
||||
},
|
||||
})
|
||||
|
||||
type ViewState = 'loading' | 'normal' | 'error' | 'maintenance'
|
||||
|
||||
const store = useAppConfigStore()
|
||||
|
||||
const viewState = ref<ViewState>('loading')
|
||||
const maintenance = ref<IPublicMaintenance | null>(null)
|
||||
const nowMs = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let refreshing = false
|
||||
|
||||
const isScheduled = computed(() => maintenance.value?.status === 'scheduled')
|
||||
const title = computed(() => maintenance.value?.title || (isScheduled.value ? '即将维护' : '站点维护中'))
|
||||
const description = computed(() => maintenance.value?.description || '')
|
||||
|
||||
/** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */
|
||||
const appLogo = computed(() => {
|
||||
const appInfo = store.configs.appConfig?.appInfo
|
||||
const logo = appInfo && typeof appInfo === 'object'
|
||||
? (appInfo as { logo?: string }).logo
|
||||
: ''
|
||||
return logo ? checkUrl(logo) : ''
|
||||
})
|
||||
|
||||
/** 倒计时目标:scheduled → startTime / active → endTime */
|
||||
const countdownTarget = computed(() => {
|
||||
const info = maintenance.value
|
||||
if (!info)
|
||||
return ''
|
||||
return isScheduled.value ? info.startTime || '' : info.endTime || ''
|
||||
})
|
||||
|
||||
const countdownPrefix = computed(() => (isScheduled.value ? '距维护开始还有' : '预计恢复还有'))
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const target = countdownTarget.value
|
||||
if (!target)
|
||||
return ''
|
||||
const remaining = new Date(target).getTime() - nowMs.value
|
||||
if (!Number.isFinite(remaining) || remaining <= 0)
|
||||
return ''
|
||||
const total = Math.floor(remaining / 1000)
|
||||
const days = Math.floor(total / 86400)
|
||||
const hours = Math.floor((total % 86400) / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const seconds = total % 60
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return days > 0
|
||||
? `${days}天${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||
})
|
||||
|
||||
function startTimer() {
|
||||
stopTimer()
|
||||
timer = setInterval(tick, 1000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 每秒刷新倒计时;目标时刻已到 → 重拉一次(服务端可能已切换 scheduled→active 或自动结束) */
|
||||
function tick() {
|
||||
nowMs.value = Date.now()
|
||||
const target = countdownTarget.value
|
||||
if (!target)
|
||||
return
|
||||
const remaining = new Date(target).getTime() - nowMs.value
|
||||
if (Number.isFinite(remaining) && remaining <= 0)
|
||||
void load(true)
|
||||
}
|
||||
|
||||
async function load(force = false) {
|
||||
if (refreshing)
|
||||
return
|
||||
refreshing = true
|
||||
viewState.value = 'loading'
|
||||
maintenance.value = null
|
||||
stopTimer()
|
||||
try {
|
||||
const { ok } = await store.bootstrap({ force })
|
||||
const info = store.configs.maintenance
|
||||
if (info) {
|
||||
maintenance.value = info
|
||||
viewState.value = 'maintenance'
|
||||
startTimer()
|
||||
}
|
||||
else if (ok) {
|
||||
// 拉取成功但无 maintenance 键:未维护(或已到点自动结束)→ 服务正常
|
||||
viewState.value = 'normal'
|
||||
}
|
||||
else {
|
||||
viewState.value = 'error'
|
||||
}
|
||||
}
|
||||
catch {
|
||||
viewState.value = 'error'
|
||||
}
|
||||
finally {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.switchTab({ url: '/pages/tabbar/home/home' })
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
stopTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="maintenance-page min-h-screen bg-[#fafafa] pb-16">
|
||||
<!-- 加载中 -->
|
||||
<view v-if="viewState === 'loading'" class="flex flex-col items-center justify-center py-48">
|
||||
<text class="text-[26rpx] text-[#999]">
|
||||
加载中...
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 服务正常(未维护) -->
|
||||
<view v-else-if="viewState === 'normal'" class="flex flex-col items-center justify-center px-10 py-48 text-center">
|
||||
<text class="text-[64rpx]">
|
||||
✅
|
||||
</text>
|
||||
<text class="mt-6 text-[28rpx] text-[#333]">
|
||||
当前服务正常,无需维护
|
||||
</text>
|
||||
<text class="mt-2 text-[24rpx] text-[#999]">
|
||||
如果仍然无法访问,请稍后重试或联系站长
|
||||
</text>
|
||||
<view class="mt-10">
|
||||
<wd-button type="primary" round @click="goHome">
|
||||
返回首页
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 拉取失败(通常为服务器停机中) -->
|
||||
<view v-else-if="viewState === 'error'" class="flex flex-col items-center justify-center px-10 py-48 text-center">
|
||||
<text class="text-[64rpx]">
|
||||
⚠️
|
||||
</text>
|
||||
<text class="mt-6 text-[28rpx] text-[#333]">
|
||||
服务暂时无法访问
|
||||
</text>
|
||||
<text class="mt-2 text-[24rpx] text-[#999]">
|
||||
站点可能正在维护中,请稍后重试
|
||||
</text>
|
||||
<view class="mt-10">
|
||||
<wd-button type="primary" plain round @click="load(true)">
|
||||
重新加载
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 维护预告 / 维护中 -->
|
||||
<view v-else class="flex flex-col items-center px-8 pt-24">
|
||||
<image
|
||||
v-if="appLogo"
|
||||
class="h-[150rpx] w-[150rpx] border border-[#eee] rounded-full bg-white"
|
||||
:src="appLogo"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<view class="mt-8 flex items-center justify-center">
|
||||
<view
|
||||
class="rounded-full px-5 py-1 text-[22rpx]"
|
||||
:class="isScheduled ? 'bg-[#e8f1ff] text-[#1e6fff]' : 'bg-[#fdeef1] text-[#f83856]'"
|
||||
>
|
||||
{{ isScheduled ? '维护预告' : '维护中' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mt-6 text-center text-[40rpx] text-[#222] font-bold">
|
||||
{{ title }}
|
||||
</view>
|
||||
|
||||
<view v-if="countdownText" class="mt-8 flex flex-col items-center">
|
||||
<text class="text-[24rpx] text-[#999]">
|
||||
{{ countdownPrefix }}
|
||||
</text>
|
||||
<text
|
||||
class="mt-2 text-[44rpx] font-bold tabular-nums"
|
||||
:class="isScheduled ? 'text-[#1e6fff]' : 'text-[#f83856]'"
|
||||
>
|
||||
{{ countdownText }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="description" class="mt-10 w-full rounded-2xl bg-white p-6">
|
||||
<mp-html
|
||||
:content="description"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="!description && !countdownText" class="mt-10 text-center text-[24rpx] text-[#999]">
|
||||
请耐心等待,维护完成后将自动恢复访问
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.maintenance-page {
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+299
-313
@@ -1,343 +1,329 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
/**
|
||||
* 关于页(源自旧项目 pages/tabbar/about/about.vue,新建复刻)
|
||||
* 功能:博主信息 + 站点统计 + 功能导航 + 版权
|
||||
* 风格:对齐全站设计语言(bg-page + uh-global-card-glass + uh-section-title + 彩色图标块)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getBlogStatistics } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { checkHasAdminLogin } from '@/utils/auth'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IBlogStats } from '@/api/types/halo'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getBlogStatistics } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { checkHasAdminLogin } from '@/utils/auth'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IBlogStats } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '关于',
|
||||
enablePullDownRefresh: true,
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '关于',
|
||||
enablePullDownRefresh: true,
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
|
||||
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
|
||||
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
|
||||
|
||||
/* ---------------- 计算属性 ---------------- */
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as
|
||||
| { nickname?: string, avatar?: string, description?: string }
|
||||
| undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
description: blogger?.description || '',
|
||||
}
|
||||
})
|
||||
/* ---------------- 计算属性 ---------------- */
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as
|
||||
| { nickname ?: string, avatar ?: string, description ?: string }
|
||||
| undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
description: blogger?.description || '',
|
||||
}
|
||||
})
|
||||
|
||||
const pageConfig = computed(() => haloConfigs.value.pageConfig?.aboutConfig as
|
||||
| { bgImageUrl?: string, waveImageUrl?: string }
|
||||
| undefined)
|
||||
const pageConfig = computed(() => haloConfigs.value.pageConfig?.aboutConfig as
|
||||
| { bgImageUrl ?: string, waveImageUrl ?: string }
|
||||
| undefined)
|
||||
|
||||
const calcProfileStyle = computed(() => ({
|
||||
backgroundImage: `url(${checkImageUrl(pageConfig.value?.bgImageUrl)})`,
|
||||
}))
|
||||
const calcProfileStyle = computed(() => ({
|
||||
backgroundImage: `url(${checkImageUrl(pageConfig.value?.bgImageUrl)})`,
|
||||
}))
|
||||
|
||||
const calcWaveUrl = computed(() => checkImageUrl(pageConfig.value?.waveImageUrl))
|
||||
const calcWaveUrl = computed(() => checkImageUrl(pageConfig.value?.waveImageUrl))
|
||||
|
||||
const basicConfig = computed(() => haloConfigs.value.basicConfig as
|
||||
| {
|
||||
copyrightConfig?: { enabled?: boolean, content?: string }
|
||||
disclaimers?: { enabled?: boolean }
|
||||
showAboutSystem?: boolean
|
||||
}
|
||||
| undefined)
|
||||
const basicConfig = computed(() => haloConfigs.value.basicConfig as
|
||||
| {
|
||||
copyrightConfig ?: { enabled ?: boolean, content ?: string }
|
||||
disclaimers ?: { enabled ?: boolean }
|
||||
showAboutSystem ?: boolean
|
||||
}
|
||||
| undefined)
|
||||
|
||||
const copyrightConfig = computed(() => basicConfig.value?.copyrightConfig)
|
||||
const copyrightConfig = computed(() => basicConfig.value?.copyrightConfig)
|
||||
|
||||
const loveEnabled = computed(() => !!(haloConfigs.value.loveConfig as { loveEnabled?: boolean } | undefined)?.loveEnabled)
|
||||
const socialEnabled = computed(() => !!(haloConfigs.value.authorConfig?.social as { enabled?: boolean } | undefined)?.enabled)
|
||||
const loveEnabled = computed(() => !!(haloConfigs.value.loveConfig as { loveEnabled ?: boolean } | undefined)?.loveEnabled)
|
||||
const socialEnabled = computed(() => !!(haloConfigs.value.authorConfig?.social as { enabled ?: boolean } | undefined)?.enabled)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const statisticsShowMore = ref(false)
|
||||
const statistics = ref<IBlogStats>({ post: 0, comment: 0, category: 0, visit: 0, upvote: 0 })
|
||||
const navList = ref<{
|
||||
key: string
|
||||
title: string
|
||||
icon: string
|
||||
iconColor: string
|
||||
rightText: string
|
||||
path: string | null
|
||||
isAdmin?: boolean
|
||||
openType?: string
|
||||
show: boolean
|
||||
}[]>([])
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const statisticsShowMore = ref(false)
|
||||
const statistics = ref<IBlogStats>({ post: 0, comment: 0, category: 0, visit: 0, upvote: 0 })
|
||||
|
||||
/* ---------------- 功能导航 ---------------- */
|
||||
async function handleGetNavList() {
|
||||
let isWx = false
|
||||
// #ifdef MP-WEIXIN
|
||||
isWx = true
|
||||
// #endif
|
||||
/** 主行统计(常驻展示) */
|
||||
const allStats = computed(() => [
|
||||
{ key: 'post', label: '内容', value: statistics.value.post },
|
||||
{ key: 'visit', label: '访客', value: statistics.value.visit },
|
||||
{ key: 'category', label: '分类', value: statistics.value.category },
|
||||
{ key: 'comment', label: '评论', value: statistics.value.comment },
|
||||
{ key: 'upvote', label: '点赞', value: statistics.value.upvote },
|
||||
])
|
||||
|
||||
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
|
||||
interface INavItem {
|
||||
key : string
|
||||
title : string
|
||||
icon : string
|
||||
/** 图标块背景色(与首页快捷导航同色板,同一功能同色) */
|
||||
bgColor : string
|
||||
rightText : string
|
||||
path : string | null
|
||||
isAdmin ?: boolean
|
||||
openType ?: string
|
||||
show : boolean
|
||||
/** 分组:blog=博客功能 more=更多信息 */
|
||||
group : 'blog' | 'more'
|
||||
}
|
||||
|
||||
navList.value = [
|
||||
{
|
||||
key: 'data-visual',
|
||||
title: '数据看板',
|
||||
icon: 'chart',
|
||||
iconColor: '#2196f3',
|
||||
rightText: '站点数据可视化',
|
||||
path: '/pages-blog/data-visual/data-visual',
|
||||
show: dataVisualAvailable,
|
||||
},
|
||||
{
|
||||
key: 'archives',
|
||||
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
|
||||
icon: 'folder',
|
||||
iconColor: '#f44336',
|
||||
rightText: calcAuditModeEnabled.value ? '全部已归档内容' : '全部已归档文章',
|
||||
path: '/pages-blog/archives/archives',
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
key: 'love',
|
||||
title: '恋爱日记',
|
||||
icon: 'heart',
|
||||
iconColor: '#f44336',
|
||||
rightText: '博主的恋爱日记',
|
||||
path: '/pages-blog/love/love',
|
||||
show: loveEnabled.value,
|
||||
},
|
||||
{
|
||||
key: 'vote',
|
||||
title: '投票中心',
|
||||
icon: 'box',
|
||||
iconColor: '#f44336',
|
||||
rightText: '查看和进行投票',
|
||||
path: '/pages-blog/votes/votes',
|
||||
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
|
||||
},
|
||||
{
|
||||
key: 'friend-links',
|
||||
title: '友情链接',
|
||||
icon: 'link',
|
||||
iconColor: '#2196f3',
|
||||
rightText: '看看博主朋友们吧',
|
||||
path: '/pages-blog/friend-links/friend-links',
|
||||
show: calcLinksPluginEnabled.value,
|
||||
},
|
||||
{
|
||||
key: 'disclaimers',
|
||||
title: '免责声明',
|
||||
icon: 'map',
|
||||
iconColor: '#f44336',
|
||||
rightText: '博客内容免责声明',
|
||||
path: '/pages-blog/disclaimers/disclaimers',
|
||||
show: !!basicConfig.value?.disclaimers?.enabled,
|
||||
},
|
||||
{
|
||||
key: 'contact-blogger',
|
||||
title: '联系博主',
|
||||
icon: 'message',
|
||||
iconColor: '#ff9800',
|
||||
rightText: '博主常用联系方式',
|
||||
path: '/pages-blog/contact/contact',
|
||||
show: socialEnabled.value,
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: '关于项目',
|
||||
icon: 'info',
|
||||
iconColor: '#2196f3',
|
||||
rightText: '小莫唐尼开源项目',
|
||||
path: '/pages-blog/about/about',
|
||||
show: !!basicConfig.value?.showAboutSystem,
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
title: '偏好设置',
|
||||
icon: 'settings',
|
||||
iconColor: '#03a9f4',
|
||||
rightText: '首页布局、卡片样式等本地偏好',
|
||||
path: '/pages-blog/setting/setting',
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
const navList = ref<INavItem[]>([])
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
try {
|
||||
const res = await getBlogStatistics()
|
||||
statistics.value = res.data
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取统计失败', err)
|
||||
uni.showToast({ icon: 'none', title: t('common.loadFailedRetry') })
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
/** 分组渲染(过滤后空组整组隐藏) */
|
||||
const calcNavGroups = computed(() => {
|
||||
const visible = navList.value.filter(n => n.show)
|
||||
const groupDefs : { key : 'blog' | 'more', title : string }[] = [
|
||||
{ key: 'blog', title: '博客功能' },
|
||||
{ key: 'more', title: '其他功能' },
|
||||
]
|
||||
return groupDefs
|
||||
.map(def => ({ ...def, items: visible.filter(n => n.group === def.key) }))
|
||||
.filter(group => group.items.length > 0)
|
||||
})
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnNav(data: { path: string | null, isAdmin?: boolean }) {
|
||||
const { path, isAdmin } = data
|
||||
if (!path)
|
||||
return
|
||||
/* ---------------- 功能导航 ---------------- */
|
||||
async function handleGetNavList() {
|
||||
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
|
||||
|
||||
// 拦截后台管理页面(需超管登录)
|
||||
if (isAdmin && !checkHasAdminLogin()) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '未登录超管账号或登录状态已过期,是否立即登录?',
|
||||
showCancel: true,
|
||||
cancelText: '否',
|
||||
cancelColor: '#999999',
|
||||
confirmText: '是',
|
||||
confirmColor: '#03a9f4',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/auth/login' })
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
navList.value = [
|
||||
{
|
||||
key: 'data-visual',
|
||||
title: '数据看板',
|
||||
icon: 'chart',
|
||||
bgColor: 'rgba(102, 60, 201, 0.95)',
|
||||
rightText: '站点数据可视化',
|
||||
path: '/pages-blog/data-visual/data-visual',
|
||||
show: dataVisualAvailable,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'archives',
|
||||
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
|
||||
icon: 'folder',
|
||||
bgColor: 'rgba(3, 169, 244, 0.95)',
|
||||
rightText: calcAuditModeEnabled.value ? '全部已归档内容' : '全部已归档文章',
|
||||
path: '/pages-blog/archives/archives',
|
||||
show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'love',
|
||||
title: '恋爱日记',
|
||||
icon: 'heart',
|
||||
bgColor: 'rgba(255, 76, 103, 0.95)',
|
||||
rightText: '博主的恋爱日记',
|
||||
path: '/pages-blog/love/love',
|
||||
show: loveEnabled.value,
|
||||
// show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'vote',
|
||||
title: '投票中心',
|
||||
icon: 'box',
|
||||
bgColor: 'rgba(0, 188, 212, 0.95)',
|
||||
rightText: '查看和进行投票',
|
||||
path: '/pages-blog/votes/votes',
|
||||
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
|
||||
// show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'friend-links',
|
||||
title: '友情链接',
|
||||
icon: 'link',
|
||||
bgColor: 'rgba(0, 150, 136, 0.95)',
|
||||
rightText: '看看博主朋友们吧',
|
||||
path: '/pages-blog/friend-links/friend-links',
|
||||
show: calcLinksPluginEnabled.value,
|
||||
// show: true,
|
||||
group: 'blog',
|
||||
},
|
||||
{
|
||||
key: 'disclaimers',
|
||||
title: '免责声明',
|
||||
icon: 'map',
|
||||
bgColor: 'rgba(121, 85, 72, 0.95)',
|
||||
rightText: '博客内容免责声明',
|
||||
path: '/pages-blog/disclaimers/disclaimers',
|
||||
show: !!basicConfig.value?.disclaimers?.enabled,
|
||||
// show: true,
|
||||
group: 'more',
|
||||
},
|
||||
{
|
||||
key: 'contact-blogger',
|
||||
title: '联系博主',
|
||||
icon: 'message',
|
||||
bgColor: 'rgba(255, 152, 0, 0.95)',
|
||||
rightText: '博主常用联系方式',
|
||||
path: '/pages-blog/contact/contact',
|
||||
show: socialEnabled.value,
|
||||
// show: true,
|
||||
group: 'more',
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: '关于项目',
|
||||
icon: 'info',
|
||||
bgColor: 'rgba(96, 125, 139, 0.95)',
|
||||
rightText: '小莫唐尼开源项目',
|
||||
path: '/pages-blog/about/about',
|
||||
show: !!basicConfig.value?.showAboutSystem,
|
||||
// show: true,
|
||||
group: 'more',
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
title: '偏好设置',
|
||||
icon: 'settings',
|
||||
bgColor: 'rgba(121, 134, 203, 0.95)',
|
||||
rightText: '首页布局、卡片样式等本地偏好',
|
||||
path: '/pages-blog/setting/setting',
|
||||
show: true,
|
||||
group: 'more',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
try {
|
||||
const res = await getBlogStatistics()
|
||||
statistics.value = res.data
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取统计失败', err)
|
||||
uni.showToast({ icon: 'none', title: t('common.loadFailedRetry') })
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
watch(haloConfigs, () => {
|
||||
handleGetNavList()
|
||||
}, { deep: true, immediate: true })
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnNav(data : { path : string | null, isAdmin ?: boolean }) {
|
||||
const { path, isAdmin } = data
|
||||
if (!path)
|
||||
return
|
||||
|
||||
handleGetData()
|
||||
// 拦截后台管理页面(需超管登录)
|
||||
if (isAdmin && !checkHasAdminLogin()) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '未登录超管账号或登录状态已过期,是否立即登录?',
|
||||
showCancel: true,
|
||||
cancelText: '否',
|
||||
cancelColor: '#999999',
|
||||
confirmText: '是',
|
||||
confirmColor: '#03a9f4',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/auth/login' })
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetData()
|
||||
})
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
watch(haloConfigs, () => {
|
||||
handleGetNavList()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
handleGetData()
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-page min-h-screen w-screen pb-6">
|
||||
<!-- 博主信息 -->
|
||||
<view class="blogger-info relative h-[600rpx] w-full" :style="[calcProfileStyle]">
|
||||
<image class="avatar absolute left-1/2 top-[200rpx] z-2 h-[130rpx] w-[130rpx] border-6 border-white rounded-full -translate-x-1/2" :src="bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="profile absolute left-0 top-[340rpx] z-6 w-full text-center text-white">
|
||||
<view class="author text-[34rpx] font-bold">
|
||||
{{ bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="desc mt-4 px-12 text-[26rpx] opacity-90">
|
||||
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
|
||||
</view>
|
||||
</view>
|
||||
<image v-if="calcWaveUrl" :src="calcWaveUrl" mode="scaleToFill" class="gif-wave absolute bottom-0 left-0 z-99 h-[100rpx] w-full" style="mix-blend-mode: screen;" />
|
||||
</view>
|
||||
<view class="box-border bg-page min-h-screen w-screen pb-8">
|
||||
<!-- 头部:博主信息(背景图 + 遮罩 + wave,内容区做状态栏适配) -->
|
||||
<view class="blogger-info relative h-76 w-full bg-cover bg-no-repeat" :style="[calcProfileStyle]">
|
||||
<!-- 背景遮罩 -->
|
||||
<view class="absolute left-0 top-0 z-0 h-full w-full backdrop-blur-[2rpx] bg-black/30" />
|
||||
<view class="relative z-6 h-full flex flex-col items-center justify-center pb-[140rpx] pt-safe">
|
||||
<image class="uh-global-card-glass h-20 w-20 rounded-full" :src="bloggerInfo.avatar"
|
||||
mode="aspectFill" />
|
||||
<view class="mt-4 text-lg text-white font-bold text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
|
||||
{{ bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view
|
||||
class="desc mt-2 px-10 text-center text-[26rpx] text-white/90 leading-relaxed text-shadow-[0_2rpx_8rpx_rgba(0,0,0,0.4)]">
|
||||
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
|
||||
</view>
|
||||
</view>
|
||||
<image v-if="calcWaveUrl" :src="calcWaveUrl" mode="scaleToFill"
|
||||
class="gif-wave absolute bottom-0 left-0 z-99 h-[100rpx] w-full" style="mix-blend-mode: screen;" />
|
||||
</view>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<view class="statistics-wrap overflow-hidden rounded-b-3xl bg-white shadow-sm">
|
||||
<view class="statistics flex pb-3 pt-3">
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #ff9800;">
|
||||
{{ statistics.post }}
|
||||
</view>
|
||||
<view class="mt-1 text-center text-[24rpx] text-[#999]">
|
||||
内容数量
|
||||
</view>
|
||||
</view>
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #4caf50;">
|
||||
{{ statistics.visit }}
|
||||
</view>
|
||||
<view class="mt-1 text-[24rpx] text-[#999]">
|
||||
访客数量
|
||||
</view>
|
||||
</view>
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #2196f3;">
|
||||
{{ statistics.category }}
|
||||
</view>
|
||||
<view class="mt-1 text-center text-[24rpx] text-[#999]">
|
||||
分类总数
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="statisticsShowMore" class="statistics flex border-t-2 border-[#fafafa] pb-3 pt-3">
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #ff9800;">
|
||||
{{ statistics.comment }}
|
||||
</view>
|
||||
<view class="mt-1 text-center text-[24rpx] text-[#999]">
|
||||
评论数量
|
||||
</view>
|
||||
</view>
|
||||
<view class="item flex-1 py-6 text-center">
|
||||
<view class="number text-[40rpx] font-bold" style="color: #2196f3;">
|
||||
{{ statistics.upvote }}
|
||||
</view>
|
||||
<view class="mt-1 text-[24rpx] text-[#999]">
|
||||
点赞数量
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="show-more-btn pb-4 text-center text-[24rpx] text-[#999]" @click="statisticsShowMore = !statisticsShowMore">
|
||||
{{ statisticsShowMore ? '收起' : '展开' }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- 站点统计(上浮玻璃卡,与头部衔接) -->
|
||||
<view class="uh-global-card-glass border relative flex z-100 mx-4 rounded-2xl -mt-12">
|
||||
<view v-for="item in allStats" :key="item.key" class="flex-1 py-6 text-center">
|
||||
<view class="text-lg text-gray-900 font-bold">
|
||||
{{ item.value }}
|
||||
</view>
|
||||
<view class="mt-1 text-xs text-gray-500">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能导航 -->
|
||||
<view class="nav-wrap mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<template v-for="nav in navList.filter(n => n.show)" :key="nav.key">
|
||||
<view class="nav-item flex items-center justify-between border-b-2 border-[#f5f5f5] px-3 py-7" @click="handleOnNav(nav)">
|
||||
<view class="nav-left flex items-center gap-4">
|
||||
<wd-icon :name="nav.icon" size="18px" :color="nav.iconColor" />
|
||||
<text class="nav-title text-[28rpx] text-[#303133]">{{ nav.title }}</text>
|
||||
</view>
|
||||
<view class="nav-right flex items-center gap-2">
|
||||
<text class="nav-right-text text-[24rpx] text-[#c0c4cc]">{{ nav.rightText }}</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#c0c4cc" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<!-- 功能导航(分组玻璃卡) -->
|
||||
<template v-for="group in calcNavGroups" :key="group.key">
|
||||
<uh-section-title class="mx-4 mb-3 mt-8">
|
||||
{{ group.title }}
|
||||
</uh-section-title>
|
||||
<view class="nav-wrap uh-global-card-glass mx-4 overflow-hidden rounded-2xl">
|
||||
<view v-for="(nav, index) in group.items" :key="nav.key"
|
||||
class="nav-item flex items-center justify-between px-4"
|
||||
:class="index < group.items.length - 1 ? 'border-b border-b-solid border-black/5' : ''" @click="handleOnNav(nav)">
|
||||
<view class="nav-left flex items-center gap-3 py-3">
|
||||
<view class="h-9 w-9 flex items-center justify-center rounded-xl"
|
||||
:style="{ backgroundColor: nav.bgColor }">
|
||||
<wd-icon :name="nav.icon" size="20px" color="#ffffff" />
|
||||
</view>
|
||||
<text class="nav-title text-sm text-gray-900 font-bold">{{ nav.title }}</text>
|
||||
</view>
|
||||
<view class="nav-right flex items-center gap-2">
|
||||
<text class="nav-right-text text-xs text-gray-400">{{ nav.rightText }}</text>
|
||||
<wd-icon name="arrow-right" size="12px" color="#c8c2b4" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 版权 -->
|
||||
<view v-if="copyrightConfig?.enabled" class="copyright mt-10 px-6 text-center text-[22rpx] text-[#c0c4c7]">
|
||||
<view>{{ copyrightConfig.content }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-page {
|
||||
.blogger-info {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: rgb(0 0 0 / 30%);
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-wrap {
|
||||
.nav-item {
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- 版权 -->
|
||||
<view v-if="copyrightConfig?.enabled" class="mt-6 px-6 text-center text-xs text-gray-400">
|
||||
<view>{{ copyrightConfig.content }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -1,183 +1,183 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getCategoryList, getCategoryPostList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkThumbnailUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import { useDataLoadingStatus, DataLoadingStatusEnum } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ICategory, IPost } from '@/api/types/halo'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getCategoryList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkThumbnailUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ICategory } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '分类',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '分类',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
|
||||
const categoryConfig = computed(() => haloConfigs.value.pageConfig?.categoryConfig)
|
||||
const categoryConfig = computed(() => haloConfigs.value.pageConfig?.categoryConfig)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const queryParams = ref({
|
||||
size: 20,
|
||||
page: 1,
|
||||
fieldSelector: ['spec.hideFromList=false'],
|
||||
})
|
||||
const hasNext = ref(false)
|
||||
const dataList = ref<ICategory[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const queryParams = ref({
|
||||
size: 20,
|
||||
page: 1,
|
||||
fieldSelector: ['spec.hideFromList=false'],
|
||||
})
|
||||
const hasNext = ref(false)
|
||||
const dataList = ref<ICategory[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
|
||||
function handleResetInit() {
|
||||
dataList.value = []
|
||||
queryParams.value.page = 1
|
||||
hasNext.value = false
|
||||
isLoadMore.value = false
|
||||
loadMoreText.value = t('common.loading')
|
||||
}
|
||||
function handleResetInit() {
|
||||
dataList.value = []
|
||||
queryParams.value.page = 1
|
||||
hasNext.value = false
|
||||
isLoadMore.value = false
|
||||
loadMoreText.value = t('common.loading')
|
||||
}
|
||||
|
||||
function handleInitPage() {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
}
|
||||
function handleInitPage() {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
// 审核模式
|
||||
if (calcAuditModeEnabled.value) {
|
||||
const auditCategoryNames = appConfigStore.auditData.spec?.categories || []
|
||||
try {
|
||||
const res = await getCategoryList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(item => auditCategoryNames.includes(item.metadata.name))
|
||||
.map(item => ({
|
||||
...item,
|
||||
postCount: item.postCount ?? 0,
|
||||
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
|
||||
}))
|
||||
const orderMap = new Map(auditCategoryNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
dataList.value = filtered
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
// 审核模式
|
||||
if (calcAuditModeEnabled.value) {
|
||||
const auditCategoryNames = appConfigStore.auditData.spec?.categories || []
|
||||
try {
|
||||
const res = await getCategoryList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(item => auditCategoryNames.includes(item.metadata.name))
|
||||
.map(item => ({
|
||||
...item,
|
||||
postCount: item.postCount ?? 0,
|
||||
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
|
||||
}))
|
||||
const orderMap = new Map(auditCategoryNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
dataList.value = filtered
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLoadMore.value) {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
if (!isLoadMore.value) {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
try {
|
||||
const res = await getCategoryList({ ...queryParams.value })
|
||||
try {
|
||||
const res = await getCategoryList({ ...queryParams.value })
|
||||
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
|
||||
const tempItems = res.data.items.map(item => ({
|
||||
...item,
|
||||
postCount: item.postCount ?? 0,
|
||||
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
|
||||
}))
|
||||
const tempItems = res.data.items.map(item => ({
|
||||
...item,
|
||||
postCount: item.postCount ?? 0,
|
||||
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
|
||||
}))
|
||||
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
function handleToCategory(category: ICategory) {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
|
||||
})
|
||||
}
|
||||
|
||||
function handleToCategory(category : ICategory) {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
|
||||
})
|
||||
}
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
|
||||
onMounted(() => {
|
||||
handleInitPage()
|
||||
})
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onPullDownRefresh(() => {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
handleInitPage()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleResetInit()
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
onReachBottom(() => {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="bg-page min-h-screen w-screen flex flex-col p-3 box-border">
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loadingStatus !== DataLoadingStatusEnum.Success">
|
||||
<uh-data-loading :loading-status="loadingStatus" />
|
||||
</view>
|
||||
<view class="box-border min-h-screen w-screen flex flex-col bg-page p-3">
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loadingStatus !== DataLoadingStatusEnum.Success">
|
||||
<uh-data-loading :loading-status="loadingStatus" />
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<view class="grid grid-cols-2 gap-3">
|
||||
<view v-for="(item, index) in dataList" :key="index"
|
||||
class="relative w-full box-border rounded-xl overflow-hidden uh-global-card-glass"
|
||||
@click="handleToCategory(item)">
|
||||
<image v-if="item.spec.cover" class="block h-32 w-full" :src="item.spec.cover" mode="aspectFill" />
|
||||
<view class="absolute bottom-0 left-0 h-[140rpx] w-full bg-gradient-to-b from-black/0 to-black/30" />
|
||||
<view class="absolute bottom-0 left-0 box-border w-full p-2.5 flex flex-col gap-1">
|
||||
<text class="text-sm text-white font-bold truncate">
|
||||
{{ item.spec.displayName }}
|
||||
</text>
|
||||
<text class="text-xs text-white opacity-80">
|
||||
共 {{ item.postCount }} 篇文章
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="w-full py-5 text-center text-xs text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
<block v-else>
|
||||
<view class="grid grid-cols-2 gap-3">
|
||||
<view
|
||||
v-for="(item, index) in dataList" :key="index"
|
||||
class="uh-global-card-glass relative box-border w-full overflow-hidden rounded-xl"
|
||||
@click="handleToCategory(item)"
|
||||
>
|
||||
<image v-if="item.spec.cover" class="block h-32 w-full" :src="item.spec.cover" mode="aspectFill" />
|
||||
<view class="absolute bottom-0 left-0 h-[140rpx] w-full from-black/0 to-black/30 bg-gradient-to-b" />
|
||||
<view class="absolute bottom-0 left-0 box-border w-full flex flex-col gap-1 p-2.5">
|
||||
<text class="truncate text-sm text-white font-bold">
|
||||
{{ item.spec.displayName }}
|
||||
</text>
|
||||
<text class="text-xs text-white opacity-80">
|
||||
共 {{ item.postCount }} 篇文章
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="w-full py-5 text-center text-xs text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getPostList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
@@ -71,7 +71,7 @@
|
||||
try {
|
||||
const res = await getPostList({ page: 1, size: 0, sort: ['spec.publishTime,desc'] })
|
||||
const filtered = res.data.items.filter(item => auditPostNames.includes(item.metadata.name))
|
||||
articleList.value = filtered.map((item)=>{
|
||||
articleList.value = filtered.map((item) => {
|
||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar);
|
||||
return item;
|
||||
})
|
||||
@@ -100,7 +100,7 @@
|
||||
result.value.hasNext = res.data.hasNext
|
||||
articleList.value = (isLoadMore.value
|
||||
? articleList.value.concat(res.data.items)
|
||||
: res.data.items).map((item)=>{
|
||||
: res.data.items).map((item) => {
|
||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar);
|
||||
return item;
|
||||
})
|
||||
@@ -147,13 +147,6 @@
|
||||
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(() => {
|
||||
uni.setNavigationBarTitle({ title: t('page.home.title') })
|
||||
})
|
||||
|
||||
watch(haloConfigs, () => {
|
||||
// 配置就绪后重新拉取(导航显隐依赖配置)
|
||||
}, { deep: true })
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
isLoadMore.value = false
|
||||
@@ -177,7 +170,9 @@
|
||||
})
|
||||
|
||||
// 首次加载
|
||||
handleQuery()
|
||||
onMounted(() => {
|
||||
handleQuery()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -191,6 +186,9 @@
|
||||
<!-- 轮播-->
|
||||
<uh-home-banner />
|
||||
|
||||
<!-- 公告 -->
|
||||
<uh-home-notify />
|
||||
|
||||
<!-- 快捷导航 -->
|
||||
<uh-home-quick-nav />
|
||||
|
||||
@@ -207,7 +205,7 @@
|
||||
</view>
|
||||
</template>
|
||||
</uh-section-title>
|
||||
|
||||
|
||||
<view v-if="articleList.length === 0" class="article-empty py-10">
|
||||
<wd-empty description="博主还没有发表任何内容~" />
|
||||
</view>
|
||||
@@ -225,4 +223,5 @@
|
||||
</block>
|
||||
</block>
|
||||
</view>
|
||||
<uh-notify-dialog />
|
||||
</template>
|
||||
@@ -1,495 +1,390 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
/**
|
||||
* 瞬间页(源自旧项目 pages/tabbar/moments/moments.vue,新建复刻)
|
||||
* 功能:瞬间卡片列表(头像/内容/图片/音频/视频/标签) + 分页加载
|
||||
* 设计:社交信息流(实心白纸卡 + 着色昵称 + 朋友圈式不缩进正文),区别于工具页的玻璃拟态
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 玻璃拟态试验:下拉/回弹露出的窗口底色对齐壁纸底部色调
|
||||
backgroundColor: '#f4efff',
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
}
|
||||
})
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname ?: string, avatar ?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
}
|
||||
})
|
||||
|
||||
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
|
||||
const siteName = computed(() => {
|
||||
const appInfo = haloConfigs.value.appConfig?.appInfo as { name?: string } | undefined
|
||||
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
|
||||
})
|
||||
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
|
||||
const siteName = computed(() => {
|
||||
const appInfo = haloConfigs.value.appConfig?.appInfo as { name ?: string } | undefined
|
||||
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
|
||||
})
|
||||
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
|
||||
type MomentCard = IMoment & {
|
||||
images?: { type?: string, url: string }[]
|
||||
videos?: { id?: string, url: string }[]
|
||||
audios?: { type?: string, url: string }[]
|
||||
spec: IMoment['spec'] & { newHtml?: string }
|
||||
}
|
||||
const dataList = ref<MomentCard[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
|
||||
type MomentCard = IMoment & {
|
||||
images ?: { type ?: string, url : string }[]
|
||||
videos ?: { id ?: string, url : string }[]
|
||||
audios ?: { type ?: string, url : string }[]
|
||||
spec : IMoment['spec'] & { newHtml ?: string }
|
||||
}
|
||||
const dataList = ref<MomentCard[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
|
||||
/** 移除内容中的 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString: string): string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
|
||||
const calcTagColors = computed(() => {
|
||||
return dataList.value.map(moment =>
|
||||
(moment.spec.tags || []).map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f')),
|
||||
)
|
||||
})
|
||||
|
||||
/** 瞬间项映射(spec.content.medium 拆分为 images/videos/audios + 内容 tag 清理 + 作者兜底) */
|
||||
function mapMomentItem(item: IMoment): MomentCard {
|
||||
const medium = (item.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = item.owner
|
||||
return {
|
||||
...item,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...item.spec,
|
||||
newHtml: removeTagLinksCompletely(item.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
}
|
||||
/** 移除内容中的 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString : string) : string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
// 审核模式:真实瞬间按 audit-data moments 过滤(数组顺序即展示顺序)
|
||||
const auditMomentNames = appConfigStore.auditData.spec?.moments || []
|
||||
try {
|
||||
const res = await getMomentList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC' && auditMomentNames.includes(x.metadata.name))
|
||||
const orderMap = new Map(auditMomentNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
const tempItems = filtered.map(mapMomentItem)
|
||||
dataList.value = tempItems
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
/** 瞬间项映射(spec.content.medium 拆分为 images/videos/audios + 内容 tag 清理 + 作者兜底) */
|
||||
function mapMomentItem(item : IMoment) : MomentCard {
|
||||
const medium = (item.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = item.owner
|
||||
return {
|
||||
...item,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...item.spec,
|
||||
newHtml: removeTagLinksCompletely(item.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
}
|
||||
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
// 审核模式:真实瞬间按 audit-data moments 过滤(数组顺序即展示顺序)
|
||||
const auditMomentNames = appConfigStore.auditData.spec?.moments || []
|
||||
try {
|
||||
const res = await getMomentList({ page: 1, size: 0 })
|
||||
const filtered = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC' && auditMomentNames.includes(x.metadata.name))
|
||||
const orderMap = new Map(auditMomentNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
const tempItems = filtered.map(mapMomentItem)
|
||||
dataList.value = tempItems
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getMomentList({ ...queryParams.value })
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
const tempItems = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC')
|
||||
.map(mapMomentItem)
|
||||
try {
|
||||
const res = await getMomentList({ ...queryParams.value })
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
const tempItems = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC')
|
||||
.map(mapMomentItem)
|
||||
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(list: { videos?: { id?: string }[] }[]) {
|
||||
stopAllVideos()
|
||||
list.map(item => item.videos || []).flat().forEach((item) => {
|
||||
if (item.id) {
|
||||
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function stopAllVideos(excludesVideoId: string | null = null) {
|
||||
Object.keys(videoContexts.value).forEach((videoId) => {
|
||||
if (!excludesVideoId || excludesVideoId !== videoId) {
|
||||
videoContexts.value[videoId]?.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(list : { videos ?: { id ?: string }[] }[]) {
|
||||
stopAllVideos()
|
||||
list.map(item => item.videos || []).flat().forEach((item) => {
|
||||
if (item.id) {
|
||||
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVideoPlay(videoId: string) {
|
||||
currentVideoId.value = videoId
|
||||
stopAllVideos(videoId)
|
||||
}
|
||||
function stopAllVideos(excludesVideoId : string | null = null) {
|
||||
Object.keys(videoContexts.value).forEach((videoId) => {
|
||||
if (!excludesVideoId || excludesVideoId !== videoId) {
|
||||
videoContexts.value[videoId]?.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVideoPause(videoId: string) {
|
||||
if (currentVideoId.value === videoId) {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
}
|
||||
function onVideoPlay(videoId : string) {
|
||||
currentVideoId.value = videoId
|
||||
stopAllVideos(videoId)
|
||||
}
|
||||
|
||||
function onVideoEnded() {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
function onVideoPause(videoId : string) {
|
||||
if (currentVideoId.value === videoId) {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handlePreview(index: number, list: { url: string }[]) {
|
||||
uni.previewImage({
|
||||
current: index,
|
||||
urls: list.map(item => item.url),
|
||||
})
|
||||
}
|
||||
function onVideoEnded() {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
|
||||
function handleToMomentDetail(moment: IMoment) {
|
||||
if (calcAuditModeEnabled.value)
|
||||
return
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
function handleToMomentDetail(moment : IMoment) {
|
||||
if (calcAuditModeEnabled.value)
|
||||
return
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time?: string): string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
})
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time ?: string) : string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
handleGetData()
|
||||
})
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="moments-page relative box-border min-h-screen w-screen flex flex-col py-6">
|
||||
<!-- 苹果风玻璃拟态试验:fixed 渐变"壁纸"(多层柔光光斑为卡片毛玻璃取色) -->
|
||||
<view class="moments-wallpaper">
|
||||
<view class="deco deco-blue" />
|
||||
<view class="deco deco-pink" />
|
||||
<view class="deco deco-lavender" />
|
||||
<view class="deco deco-cyan" />
|
||||
<view class="deco deco-lift" />
|
||||
</view>
|
||||
<uh-plugin-unavailable
|
||||
v-if="!uniHaloPluginAvailable"
|
||||
:plugin-id="uniHaloPluginId"
|
||||
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 class="box-border min-h-screen w-screen flex flex-col bg-page py-4">
|
||||
<uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="uniHaloPluginId"
|
||||
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员" @on-refresh="handleGetData" />
|
||||
<template v-else>
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading === 'loading'" class="loading-wrap p-3">
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</view>
|
||||
|
||||
<view v-else class="flex flex-col gap-y-4 p-4">
|
||||
<view v-if="dataList.length === 0" class="min-h-[70vh] w-full flex items-center justify-center content-empty">
|
||||
<wd-empty :description="t('common.empty')" />
|
||||
</view>
|
||||
<!-- 加载失败(可重试) -->
|
||||
<uh-data-loading v-else-if="loading === 'error'" :loading-status="loading" min-height="60vh"
|
||||
error-text="瞬间加载失败,请点击重试" @refresh="handleGetData" />
|
||||
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(玻璃) -->
|
||||
<view v-for="moment in dataList" :key="moment.metadata.name" class="moment-glass flex flex-col overflow-hidden rounded-[32rpx]">
|
||||
<view class="head flex items-center p-3 pb-0">
|
||||
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="nickname ml-3">
|
||||
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="release-time mt-1 text-[24rpx] text-[#666]">
|
||||
{{ formatMomentTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="flex flex-col gap-3 px-4">
|
||||
<view v-if="dataList.length === 0"
|
||||
class="min-h-[70vh] w-full flex items-center justify-center content-empty">
|
||||
<wd-empty :description="t('common.empty')" />
|
||||
</view>
|
||||
|
||||
<view class="moment-content px-3 py-2" @click.stop="handleToMomentDetail(moment)">
|
||||
<mp-html
|
||||
class="evan-markdown"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
:loading-img="markdownConfig.loadingGif"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
:content="moment.spec.newHtml || ''"
|
||||
:markdown="true"
|
||||
:show-line-number="true"
|
||||
:show-language-name="true"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(社交信息流:着色昵称 + 朋友圈式不缩进正文 + 媒体九宫格 + 内嵌互动脚注) -->
|
||||
<view v-for="(moment, mIndex) in dataList" :key="moment.metadata.name"
|
||||
class="moment-card uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white">
|
||||
<!-- 作者 -->
|
||||
<view class="box-border flex items-center px-4 pt-4">
|
||||
<view class="flex-1 flex items-center">
|
||||
<image class="avatar h-[72rpx] w-[72rpx] shrink-0 rounded-full"
|
||||
:src="checkAvatarUrl(moment.owner?.avatar || bloggerInfo.avatar)"
|
||||
mode="aspectFill" />
|
||||
<view class="ml-3 flex flex-col">
|
||||
<view class="text-sm text-gray-900 font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="mt-0.5 text-xs text-gray-400">
|
||||
{{ formatMomentTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="shrink-0">
|
||||
<uh-button custom-class="!py-1 bg-secondary font-semibold">详情</uh-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 图片 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0" class="images flex flex-wrap items-start px-3 pb-6" :class="`images-${moment.images.length}`">
|
||||
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex" class="image-item box-border p-1" :class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
|
||||
<image
|
||||
mode="aspectFill"
|
||||
class="image-src h-full w-full rounded-lg"
|
||||
:src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 正文-->
|
||||
<view class="moment-content px-4 pt-3 ">
|
||||
|
||||
<!-- 音频 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0" class="audio-list mb-3 flex flex-col gap-3 px-3">
|
||||
<uh-audio-player
|
||||
v-for="audio in moment.audios"
|
||||
:key="audio.url"
|
||||
:src="audio.url"
|
||||
:poster="bloggerInfo.avatar"
|
||||
:name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0"
|
||||
class="mb-3 flex flex-wrap gap-x-2">
|
||||
<text v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex"
|
||||
class="py-1 px-2 text-xs rounded-xl bg-secondary">
|
||||
# {{ tag }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0" class="video-list mb-3 flex flex-col gap-3 px-3">
|
||||
<video
|
||||
v-for="(video, index) in moment.videos"
|
||||
:id="`video_${video.id}`"
|
||||
:key="index"
|
||||
class="video-src h-[400rpx] w-full rounded-xl"
|
||||
:src="video.url"
|
||||
:show-mute-btn="true"
|
||||
:controls="true"
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
@play="onVideoPlay(video.id || '')"
|
||||
@pause="onVideoPause(video.id || '')"
|
||||
@ended="onVideoEnded"
|
||||
/>
|
||||
</view>
|
||||
<mp-html 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
|
||||
@click.stop="handleToMomentDetail(moment)" />
|
||||
</view>
|
||||
|
||||
<!-- 标签 -->
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-4 px-3 pb-6">
|
||||
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="tag text-[24rpx]" :style="{ color: randomTagColor() }">
|
||||
{{ tag }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- 图片 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0"
|
||||
class="images flex flex-wrap items-start px-4 pt-3">
|
||||
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex"
|
||||
class="image-item box-border p-1"
|
||||
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
|
||||
<image mode="aspectFill" class="image-src h-full w-full rounded-lg" :src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 互动数据(点赞/评论) -->
|
||||
<view class="flex items-center justify-end gap-7 px-4 pb-4 text-[24rpx] text-[#8a919e]">
|
||||
<view class="flex items-center gap-1">
|
||||
<wd-icon name="heart" size="14px" color="#f08585" />
|
||||
<text>{{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-1">
|
||||
<wd-icon name="message" size="14px" color="#9aa3b2" />
|
||||
<text>{{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 音频 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0"
|
||||
class="audio-list flex flex-col gap-3 px-4 pt-3">
|
||||
<uh-audio-player v-for="audio in moment.audios" :key="audio.url" :src="audio.url"
|
||||
:poster="bloggerInfo.avatar" :name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname" />
|
||||
</view>
|
||||
|
||||
<view class="fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full moment-glass" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||
</view>
|
||||
<view class="load-text pb-5 text-center text-[24rpx] text-[#999]">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0"
|
||||
class="video-list flex flex-col gap-3 px-4 pt-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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 苹果风玻璃拟态试验(测试点:瞬间页)
|
||||
* 原理:页面固定一层多彩渐变"壁纸",卡片用半透明白 + backdrop-filter,
|
||||
* 壁纸的颜色透过玻璃才看得见(纯白背景看不出毛玻璃)。
|
||||
*/
|
||||
.moments-page {
|
||||
/* 兜底底色(壁纸固定层异常时页面不至于纯白) */
|
||||
background-color: #eef1fd;
|
||||
}
|
||||
<!-- (点赞/评论) -->
|
||||
<view
|
||||
class="mt-3 mb-1 box-border w-full flex items-center justify-center gap-x-12 border-t border-black/5 py-3 text-xs text-gray-400">
|
||||
<view class="flex items-center gap-x-2">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-kiss-" size="32rpx" />
|
||||
<text class="text-sm text-gray-600">点赞 {{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-x-2">
|
||||
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
|
||||
<text class="text-sm text-gray-600">评论 {{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
.moments-wallpaper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
/* 通栏渐变铺满整屏(随视口固定):顶部白衔接导航栏,中段淡蓝紫,底部淡粉回环 */
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#ffffff 0%,
|
||||
#f3f6ff 20%,
|
||||
#edf0ff 46%,
|
||||
#f6eeff 68%,
|
||||
#ffeef6 88%,
|
||||
#f4f7ff 100%
|
||||
);
|
||||
}
|
||||
</view>
|
||||
|
||||
/* 柔光光斑:以软径向渐变直接呈现"虚化"质感(免 filter blur,低端机零开销),
|
||||
* 分布覆盖整屏,让玻璃卡片在任何位置都有色可"取" */
|
||||
.deco {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60rpx);
|
||||
}
|
||||
|
||||
.deco-blue {
|
||||
width: 64%;
|
||||
height: 64%;
|
||||
right: -18%;
|
||||
top: -14%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 85%) 0%, rgb(124 163 255 / 42%) 22%, rgb(96 140 255 / 30%) 42%, transparent 68%);
|
||||
}
|
||||
|
||||
.deco-pink {
|
||||
width: 48%;
|
||||
height: 48%;
|
||||
left: -14%;
|
||||
top: 16%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 80%) 0%, rgb(255 122 176 / 32%) 26%, rgb(255 110 160 / 20%) 48%, transparent 72%);
|
||||
}
|
||||
|
||||
.deco-lavender {
|
||||
width: 54%;
|
||||
height: 54%;
|
||||
right: -10%;
|
||||
top: 42%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 75%) 0%, rgb(170 132 255 / 28%) 30%, rgb(158 120 255 / 18%) 50%, transparent 72%);
|
||||
}
|
||||
|
||||
.deco-cyan {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
left: -16%;
|
||||
bottom: -18%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 70%) 0%, rgb(90 216 236 / 24%) 30%, rgb(70 200 226 / 16%) 52%, transparent 72%);
|
||||
}
|
||||
|
||||
/* 中部柔和提亮,避免大面积素色发闷 */
|
||||
.deco-lift {
|
||||
width: 42%;
|
||||
height: 42%;
|
||||
left: 28%;
|
||||
bottom: 6%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 55%), transparent 70%);
|
||||
}
|
||||
|
||||
.moment-glass {
|
||||
background-color: rgb(255 255 255 / 55%);
|
||||
border: 1rpx solid rgb(255 255 255 / 65%);
|
||||
box-shadow:
|
||||
inset 0 1rpx 0 rgb(255 255 255 / 75%),
|
||||
0 8rpx 32rpx rgb(90 105 200 / 14%);
|
||||
backdrop-filter: blur(24rpx) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(24rpx) saturate(160%);
|
||||
|
||||
/* 低端安卓 WebView 不支持 backdrop-filter 的兜底:提高不透明度保证可读性 */
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
background-color: rgb(255 255 255 / 88%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<view class="load-text pb-5 pt-1 text-center text-xs text-gray-500">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
@@ -0,0 +1,423 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 瞬间页(源自旧项目 pages/tabbar/moments/moments.vue,新建复刻)
|
||||
* 功能:瞬间卡片列表(头像/内容/图片/音频/视频/标签) + 分页加载
|
||||
* 设计:固定壁纸光斑层为卡片毛玻璃取色(苹果风玻璃拟态)
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getMomentList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||
import { randomTagColor } from '@/utils/random'
|
||||
import { t } from '@/locale'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import type { IMoment } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '瞬间',
|
||||
enablePullDownRefresh: true,
|
||||
// 下拉/回弹露出的窗口底色对齐页面底色,壁纸光斑由页面内提供
|
||||
backgroundColor: '#f6f3ee',
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
|
||||
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
|
||||
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
avatar: checkAvatarUrl(blogger?.avatar),
|
||||
}
|
||||
})
|
||||
|
||||
/** 站点名称(原 startConfig.title 已随启动页下线,改读 appConfig.appInfo.name) */
|
||||
const siteName = computed(() => {
|
||||
const appInfo = haloConfigs.value.appConfig?.appInfo as { name?: string } | undefined
|
||||
return appInfo?.name || bloggerInfo.value.nickname || 'uni-halo'
|
||||
})
|
||||
|
||||
/** 依赖插件(plugin-moments) */
|
||||
const uniHaloPluginId = 'plugin-moments'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const queryParams = ref({ size: 10, page: 1 })
|
||||
const hasNext = ref(false)
|
||||
/** 列表卡片:medium 已按类型拆为 images/videos/audios + 正文 tag 清理 */
|
||||
type MomentCard = IMoment & {
|
||||
images?: { type?: string, url: string }[]
|
||||
videos?: { id?: string, url: string }[]
|
||||
audios?: { type?: string, url: string }[]
|
||||
spec: IMoment['spec'] & { newHtml?: string }
|
||||
}
|
||||
const dataList = ref<MomentCard[]>([])
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
|
||||
const currentVideoId = ref<string | null>(null)
|
||||
|
||||
/** 标签颜色(随机模式下按数据稳定,避免每次渲染重新随机变色) */
|
||||
const calcTagColors = computed(() => {
|
||||
return dataList.value.map(moment =>
|
||||
(moment.spec.tags || []).map(() => (calcUseTagRandomColor.value ? randomTagColor() : '#4d7c0f')),
|
||||
)
|
||||
})
|
||||
|
||||
/** 移除内容中的 tag 链接 */
|
||||
function removeTagLinksCompletely(htmlString: string): string {
|
||||
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
|
||||
return htmlString.replace(regex, '')
|
||||
}
|
||||
|
||||
/** 瞬间项映射(spec.content.medium 拆分为 images/videos/audios + 内容 tag 清理 + 作者兜底) */
|
||||
function mapMomentItem(item: IMoment): MomentCard {
|
||||
const medium = (item.spec.content?.medium || [])
|
||||
.map(x => ({ ...x, url: x.url || '' }))
|
||||
const owner = item.owner
|
||||
return {
|
||||
...item,
|
||||
// 无顶层 owner(如个别历史接口)时兜底为博主信息
|
||||
owner: owner?.displayName
|
||||
? owner
|
||||
: { displayName: bloggerInfo.value.nickname || '', name: bloggerInfo.value.nickname || '', avatar: bloggerInfo.value.avatar },
|
||||
spec: {
|
||||
...item.spec,
|
||||
newHtml: removeTagLinksCompletely(item.spec.content?.html || ''),
|
||||
},
|
||||
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
|
||||
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
|
||||
audios: medium.filter(x => x.type === 'AUDIO'),
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
// 审核模式:真实瞬间按 audit-data moments 过滤(数组顺序即展示顺序)
|
||||
const auditMomentNames = appConfigStore.auditData.spec?.moments || []
|
||||
try {
|
||||
const res = await getMomentList({ page: 1, size: 99999 })
|
||||
const filtered = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC' && auditMomentNames.includes(x.metadata.name))
|
||||
const orderMap = new Map(auditMomentNames.map((name, index) => [name, index]))
|
||||
filtered.sort((a, b) => (orderMap.get(a.metadata.name) ?? 999) - (orderMap.get(b.metadata.name) ?? 999))
|
||||
const tempItems = filtered.map(mapMomentItem)
|
||||
dataList.value = tempItems
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = t('common.noMore')
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
uni.showLoading({ mask: true, title: t('common.loading') })
|
||||
if (!isLoadMore.value) {
|
||||
loading.value = 'loading'
|
||||
}
|
||||
loadMoreText.value = t('common.loading')
|
||||
|
||||
try {
|
||||
const res = await getMomentList({ ...queryParams.value })
|
||||
loading.value = 'success'
|
||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||
hasNext.value = res.data.hasNext
|
||||
|
||||
const tempItems = res.data.items
|
||||
.filter(x => x.spec.visible === 'PUBLIC')
|
||||
.map(mapMomentItem)
|
||||
|
||||
dataList.value = isLoadMore.value
|
||||
? dataList.value.concat(tempItems)
|
||||
: tempItems
|
||||
|
||||
nextTick(() => {
|
||||
createVideoContexts(tempItems)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
loading.value = 'error'
|
||||
loadMoreText.value = t('common.loadFailed')
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 视频互斥 ---------------- */
|
||||
function createVideoContexts(list: { videos?: { id?: string }[] }[]) {
|
||||
stopAllVideos()
|
||||
list.map(item => item.videos || []).flat().forEach((item) => {
|
||||
if (item.id) {
|
||||
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stopAllVideos(excludesVideoId: string | null = null) {
|
||||
Object.keys(videoContexts.value).forEach((videoId) => {
|
||||
if (!excludesVideoId || excludesVideoId !== videoId) {
|
||||
videoContexts.value[videoId]?.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVideoPlay(videoId: string) {
|
||||
currentVideoId.value = videoId
|
||||
stopAllVideos(videoId)
|
||||
}
|
||||
|
||||
function onVideoPause(videoId: string) {
|
||||
if (currentVideoId.value === videoId) {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoEnded() {
|
||||
currentVideoId.value = null
|
||||
}
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handlePreview(index: number, list: { url: string }[]) {
|
||||
uni.previewImage({
|
||||
current: index,
|
||||
urls: list.map(item => item.url),
|
||||
})
|
||||
}
|
||||
|
||||
function handleToMomentDetail(moment: IMoment) {
|
||||
if (calcAuditModeEnabled.value)
|
||||
return
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 格式化瞬间时间 */
|
||||
function formatMomentTime(time?: string): string {
|
||||
// 与旧项目一致:yyyy年MM月dd日 星期w
|
||||
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(async () => {
|
||||
uni.setNavigationBarTitle({ title: t('page.moments.title') })
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (!uniHaloPluginAvailable.value) {
|
||||
uni.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
videoContexts.value = {}
|
||||
currentVideoId.value = null
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!uniHaloPluginAvailable.value)
|
||||
return
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
}
|
||||
if (hasNext.value) {
|
||||
queryParams.value.page += 1
|
||||
isLoadMore.value = true
|
||||
handleGetData()
|
||||
}
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="relative box-border min-h-screen w-screen flex flex-col bg-page py-6">
|
||||
<!-- 壁纸(柔和光斑为卡片毛玻璃取色,固定不随滚动) -->
|
||||
<view class="pointer-events-none fixed inset-0 z-0 overflow-hidden">
|
||||
<view class="absolute h-[420rpx] w-[420rpx] rounded-full bg-[rgba(103,164,242,0.15)] -left-[120rpx] -top-[60rpx]" />
|
||||
<view class="absolute top-[260rpx] h-[360rpx] w-[360rpx] rounded-full bg-[rgba(244,143,177,0.14)] -right-[130rpx]" />
|
||||
<view class="absolute top-[700rpx] h-[400rpx] w-[400rpx] rounded-full bg-[rgba(179,157,219,0.13)] -left-[150rpx]" />
|
||||
<view class="absolute top-[1120rpx] h-[380rpx] w-[380rpx] rounded-full bg-[rgba(77,208,235,0.12)] -right-[110rpx]" />
|
||||
<view class="absolute left-[180rpx] top-[1560rpx] h-[420rpx] w-[420rpx] rounded-full bg-[rgba(185,228,36,0.14)]" />
|
||||
</view>
|
||||
|
||||
<uh-plugin-unavailable
|
||||
v-if="!uniHaloPluginAvailable"
|
||||
:plugin-id="uniHaloPluginId"
|
||||
error-text="检测到当前插件没有安装或者启用,无法使用瞬间功能哦,请联系管理员"
|
||||
@on-refresh="handleGetData"
|
||||
/>
|
||||
<template v-else>
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading === 'loading'" class="loading-wrap p-3">
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</view>
|
||||
|
||||
<!-- 加载失败(可重试) -->
|
||||
<uh-data-loading
|
||||
v-else-if="loading === 'error'"
|
||||
:loading-status="loading"
|
||||
min-height="60vh"
|
||||
error-text="瞬间加载失败,请点击重试"
|
||||
@refresh="handleGetData"
|
||||
/>
|
||||
|
||||
<view v-else class="relative z-1 flex flex-col gap-y-4 p-4">
|
||||
<view v-if="dataList.length === 0" class="min-h-[70vh] w-full flex items-center justify-center content-empty">
|
||||
<wd-empty :description="t('common.empty')" />
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<!-- 瞬间卡片(玻璃) -->
|
||||
<view v-for="(moment, mIndex) in dataList" :key="moment.metadata.name" class="uh-global-card-glass flex flex-col overflow-hidden rounded-[32rpx]">
|
||||
<view class="head flex items-center p-3 pb-0">
|
||||
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 border-2 border-white/80 rounded-full" :src="moment.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
|
||||
<view class="nickname ml-3">
|
||||
<view class="nickname-text text-[30rpx] text-gray-900 font-bold">
|
||||
{{ moment.owner?.displayName || bloggerInfo.nickname }}
|
||||
</view>
|
||||
<view class="release-time mt-1 text-[24rpx] text-gray-400">
|
||||
{{ formatMomentTime(moment.spec.releaseTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="moment-content px-3 py-2" @click.stop="handleToMomentDetail(moment)">
|
||||
<mp-html
|
||||
class="evan-markdown"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
:loading-img="markdownConfig.loadingGif"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
:content="moment.spec.newHtml || ''"
|
||||
:markdown="true"
|
||||
:show-line-number="true"
|
||||
:show-language-name="true"
|
||||
copy-by-long-press
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 图片 -->
|
||||
<view v-if="moment.images && moment.images.length !== 0" class="images flex flex-wrap items-start px-3 pb-4">
|
||||
<view
|
||||
v-for="(image, mediumIndex) in moment.images"
|
||||
:key="mediumIndex"
|
||||
class="image-item box-border p-1"
|
||||
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')"
|
||||
>
|
||||
<image
|
||||
mode="aspectFill"
|
||||
class="image-src h-full w-full rounded-lg"
|
||||
:src="image.url"
|
||||
@click="handlePreview(mediumIndex, moment.images || [])"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 音频 -->
|
||||
<view v-if="moment.audios && moment.audios.length !== 0" class="audio-list mb-3 flex flex-col gap-3 px-3">
|
||||
<uh-audio-player
|
||||
v-for="audio in moment.audios"
|
||||
:key="audio.url"
|
||||
:src="audio.url"
|
||||
:poster="bloggerInfo.avatar"
|
||||
:name="`来自${siteName}的声音`"
|
||||
:author="bloggerInfo.nickname"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.videos && moment.videos.length !== 0" class="video-list mb-3 flex flex-col gap-3 px-3">
|
||||
<video
|
||||
v-for="(video, index) in moment.videos"
|
||||
:id="`video_${video.id}`"
|
||||
:key="index"
|
||||
class="video-src h-[400rpx] w-full rounded-xl"
|
||||
:src="video.url"
|
||||
:show-mute-btn="true"
|
||||
:controls="true"
|
||||
:show-center-play-btn="true"
|
||||
:enable-progress-gesture="true"
|
||||
@play="onVideoPlay(video.id || '')"
|
||||
@pause="onVideoPause(video.id || '')"
|
||||
@ended="onVideoEnded"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 标签 -->
|
||||
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-2 px-3 pb-4 pt-1">
|
||||
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="rounded-full bg-black/5 px-3 py-1 text-[22rpx] font-bold" :style="{ color: calcTagColors[mIndex]?.[tagIndex] }">
|
||||
# {{ tag }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 互动数据(点赞/评论) -->
|
||||
<view class="flex items-center justify-end gap-2 px-4 pb-4">
|
||||
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
|
||||
<wd-icon name="heart" size="12px" color="#f08585" />
|
||||
<text>{{ moment.stats.upvote || 0 }}</text>
|
||||
</view>
|
||||
<view class="flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-[22rpx] text-gray-500">
|
||||
<wd-icon name="message" size="12px" color="#7f8ea3" />
|
||||
<text>{{ moment.stats.totalComment || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="to-top-btn uh-global-card-glass fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
|
||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||
</view>
|
||||
<view class="load-text pb-5 text-center text-[24rpx] text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
+70
-1
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { getAppConfigs, getAuditData } from '@/api/uni-halo'
|
||||
import { getAppConfigs, getAuditData, getLoveConfig } from '@/api/uni-halo'
|
||||
import { DefaultAppConfigs } from '@/config/appConfig'
|
||||
import { deepMerge } from '@/utils/merge'
|
||||
import { setCache } from '@/utils/storage'
|
||||
@@ -14,6 +14,26 @@ import type { IAppConfig, IAuditDataResult } from '@/api/types/uni-halo'
|
||||
const APP_TOKENS_KEY = 'APP_TOKENS'
|
||||
/** 合并后配置缓存 key(与 utils/url.ts / api/uni-halo.ts 的 APP_GLOBAL_CONFIGS 读取保持一致) */
|
||||
const APP_GLOBAL_CONFIGS_KEY = 'APP_GLOBAL_CONFIGS'
|
||||
/**
|
||||
* 恋爱配置内容(公开 GET /love-config 返回: enabled + 纪念日/恋人信息;
|
||||
* 恋爱开关与页面图片仍在 configs.loveConfig, 由 love.vue 组合读取)
|
||||
*/
|
||||
export interface IStaticLoveConfig {
|
||||
enabled?: boolean
|
||||
loveDateTitle?: string
|
||||
loveDate?: string
|
||||
loveInfo?: {
|
||||
boyNickname?: string
|
||||
boyAvatar?: string
|
||||
girlNickname?: string
|
||||
girlAvatar?: string
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 静态配置后台刷新间隔(ms): onShow 距上次拉取在 TTL 内则不再重复请求(设计见
|
||||
* .docs/static-config-unified-fetch-design.md)
|
||||
*/
|
||||
const STATIC_TTL = 5 * 60 * 1000
|
||||
|
||||
export const useAppConfigStore = defineStore(
|
||||
'appConfig',
|
||||
@@ -23,11 +43,18 @@ export const useAppConfigStore = defineStore(
|
||||
const auditData = ref<IAuditDataResult>({ enabled: false })
|
||||
/** 审核模式开关(单一数据源:公开接口返回的 enabled) */
|
||||
const auditModeEnabled = computed(() => !!auditData.value.enabled)
|
||||
/** 维护模式信息(configs 顶层 additive maintenance 键;undefined=未维护/已到点自动结束) */
|
||||
const maintenance = computed(() => configs.value.maintenance)
|
||||
/** 恋爱配置内容(公开 /love-config;bootstrap 统一拉取,随 store persist 持久化) */
|
||||
const loveConfig = ref<IStaticLoveConfig>({ enabled: false })
|
||||
/** 最近一次静态配置拉取时间戳(ms,0=从未拉取;TTL 判定依据) */
|
||||
const fetchedAt = ref(0)
|
||||
|
||||
/** 重置为默认配置 */
|
||||
const setDefaultAppSettings = () => {
|
||||
configs.value = JSON.parse(JSON.stringify(DefaultAppConfigs))
|
||||
auditData.value = { enabled: false }
|
||||
loveConfig.value = { enabled: false }
|
||||
}
|
||||
|
||||
/** 获取应用配置(与默认值深合并,并存储 token) */
|
||||
@@ -71,13 +98,55 @@ export const useAppConfigStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取恋爱配置内容(公开 /love-config) */
|
||||
const fetchLoveConfig = async () => {
|
||||
try {
|
||||
const res = await getLoveConfig()
|
||||
const body = res.data as unknown as IStaticLoveConfig | undefined
|
||||
loveConfig.value = body && Object.keys(body).length > 0
|
||||
? body
|
||||
: { enabled: false }
|
||||
return loveConfig.value
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取恋爱配置失败', err)
|
||||
loveConfig.value = { enabled: false }
|
||||
return loveConfig.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一拉取静态配置(设计见 .docs/static-config-unified-fetch-design.md):
|
||||
* getConfigs + audit-data + love-config 并行一次;TTL 内(默认 5 分钟,persist 恢复后
|
||||
* 亦生效)直接返回缓存,避免每次冷启动/onShow 重复请求;force=true 强制刷新。
|
||||
* 返回 ok = getConfigs 是否成功(失败时走内置默认/旧缓存,由调用方决定后续)。
|
||||
*/
|
||||
const bootstrap = async (options?: { force?: boolean }): Promise<{ ok: boolean, fromCache: boolean }> => {
|
||||
const force = options?.force ?? false
|
||||
if (!force && fetchedAt.value > 0 && Date.now() - fetchedAt.value < STATIC_TTL) {
|
||||
return { ok: true, fromCache: true }
|
||||
}
|
||||
const [cfg] = await Promise.all([fetchConfigs(), fetchAuditData(), fetchLoveConfig()])
|
||||
fetchedAt.value = Date.now()
|
||||
return { ok: !!cfg, fromCache: false }
|
||||
}
|
||||
|
||||
/** 强制刷新静态配置(下拉刷新/设置页手动刷新入口) */
|
||||
const refreshStatic = () => bootstrap({ force: true })
|
||||
|
||||
return {
|
||||
configs,
|
||||
auditData,
|
||||
auditModeEnabled,
|
||||
maintenance,
|
||||
loveConfig,
|
||||
fetchedAt,
|
||||
fetchConfigs,
|
||||
setDefaultAppSettings,
|
||||
fetchAuditData,
|
||||
fetchLoveConfig,
|
||||
bootstrap,
|
||||
refreshStatic,
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 测试用的 iconfont,可生效
|
||||
@import './iconfont.css';
|
||||
@import './uhemoji-iconfont.css';
|
||||
|
||||
:root,
|
||||
page {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user