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

feat: 重构评论交互与请求错误处理,新增搜索页

- 请求层:非 200 响应统一抛 UniHaloError,Halo 来源响应归一化
- 评论弹窗:新增 allowNotification、验证码错误自动刷新与错误详情提示
- 评论回复结构调整为分页结果(replies.items)
- 首页:快捷导航改 5 列、最新列表跳转搜索页,移除旧文章列表页
- 图库:分类切换改用 wd-tabs/wd-tab 组件
- 新增搜索页、uh-data-loading 组件、useDataLoadingStatus 与 exception 工具

Co-Authored-By: AtomCode (deepseek-v4-flash) <noreply@atomgit.com>
This commit is contained in:
小莫唐尼
2026-09-02 00:51:43 +08:00
parent 067f3ed98a
commit 254d0b924b
19 changed files with 737 additions and 623 deletions
+3 -1
View File
@@ -48,4 +48,6 @@ src/pages.json
# npx @dcloudio/uvm@latest
.docs/
.docs/
.screenshots/
.design/
+5
View File
@@ -4,6 +4,7 @@ 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
@@ -11,6 +12,10 @@ const router = proxy?.$router
router && permission.install(router)
onLaunch((options) => {
// 初始化获取配置
useAppConfigStore()
console.log('App.vue onLaunch', options)
})
onShow((options) => {
+1 -4
View File
@@ -1,9 +1,5 @@
/**
* Halo 官方 API 接口定义
* 覆盖官方扩展点:api.content.halo.run / api.halo.run / api.moment.halo.run /
* api.photo.halo.run / api.link.halo.run / api.plugin.halo.run
* 风格参考 src/api/foo-alova.ts:http.Get<IResponse<T>>(url, { params, header, meta })
* 源自旧项目 api/v2/all.api.js(官方部分),按需命名导出
*/
import { http } from '@/http/alova';
import { RequestFrom } from '@/http/tools/enum';
@@ -144,6 +140,7 @@ export function getPostCommentReplyList(commentName: string, params: ICommentLis
/** 新增评论(带验证码,captchaCode 转入请求头) */
export interface IAddCommentReq {
allowNotification: boolean;
raw: string;
content?: string;
owner?: Record<string, unknown>;
+3 -1
View File
@@ -114,6 +114,8 @@ export type IPostListRes = IListResult<IPost>
/** 文章搜索请求参数(关键字) */
export interface ISearchReq {
keyword?: string
/** 返回条数上限(旧版默认 50) */
limit?: number
page?: number
size?: number
highlightPreTag?: string
@@ -226,7 +228,7 @@ export interface IComment {
replyCount?: number
visibleTime?: string
}
replies?: ICommentReply[]
replies?: IListResult<ICommentReply>
}
export interface ICommentReply {
+2 -1
View File
@@ -284,7 +284,8 @@ export function getDoubanDetail(url: string) {
*/
export function getCommentWidgetCaptcha() {
return http.Get<IResponse<string>>('/apis/api.commentwidget.halo.run/v1alpha1/captcha/-/generate', {
meta: { requestFrom: RequestFrom.Halo },
cacheFor:0,
meta: { requestFrom: RequestFrom.Halo },
})
}
@@ -155,9 +155,9 @@ handleGetData()
/>
<!-- 二级评论 -->
<template v-if="comment.replies && comment.replies.length !== 0">
<template v-if="comment.replies && comment.replies.items.length !== 0">
<uh-comment-item
v-for="childComment in comment.replies"
v-for="childComment in comment.replies.items"
:key="childComment.metadata.name"
:use-content-bg="false"
:is-child="true"
@@ -1,321 +1,336 @@
<script lang="ts" setup>
/**
* 评论弹窗(源自旧项目 components/comment-modal,新建复刻)
* 支持新增评论 / 回复评论,含匿名评论验证码(cookie 链路)
*/
import { computed, ref, watch } from 'vue'
import { addPostComment, addPostCommentReply } from '@/api/halo'
import { getCommentWidgetCaptcha, getCommentWidgetConfig } from '@/api/uni-halo'
import { setCache } from '@/utils/storage'
import { deepMerge } from '@/utils/merge'
import { computed, ref, watch } from 'vue'
import { addPostComment, addPostCommentReply } from '@/api/halo'
import { getCommentWidgetCaptcha, getCommentWidgetConfig } from '@/api/uni-halo'
import { setCache } from '@/utils/storage'
import { deepMerge } from '@/utils/merge'
import { UniHaloError } from '@/http/tools/exception'
const props = withDefaults(defineProps<{
show : boolean
isComment ?: boolean
title ?: string
postName : string
}>(), {
isComment: false,
title: '',
})
const props = withDefaults(defineProps<{
show: boolean
isComment?: boolean
title?: string
postName: string
}>(), {
isComment: false,
title: '',
})
const emit = defineEmits<{
(e : 'on-close', data : { isSubmit : boolean, refresh : boolean }) : void
}>()
const emit = defineEmits<{
(e: 'on-close', data: { isSubmit: boolean, refresh: boolean }): void
}>()
const isShow = ref(false)
const isShow = ref(false)
interface ICaptchaConfig {
security ?: {
captcha ?: {
anonymousCommentCaptcha ?: boolean
[key : string] : unknown
}
}
editor ?: {
placeholder ?: string
}
[key : string] : unknown
}
interface ICaptchaConfig {
security?: {
captcha?: {
anonymousCommentCaptcha?: boolean
[key: string]: unknown
}
}
editor?: {
placeholder?: string
}
[key: string]: unknown
}
interface ICommentForm {
allowNotification : boolean
author : string
avatar : string
authorUrl : string
content : string
email : string
postName : string
captchaCode ?: string
}
interface ICommentForm {
allowNotification: boolean
author: string
avatar: string
authorUrl: string
content: string
email: string
postName: string
captchaCode?: string
}
const config = ref<ICaptchaConfig>({
security: {
captcha: {
anonymousCommentCaptcha: false,
},
},
editor: {
placeholder: '请输入内容,不超过200字符...',
},
})
const config = ref<ICaptchaConfig>({
security: {
captcha: {
anonymousCommentCaptcha: false,
},
},
editor: {
placeholder: '请输入内容,不超过200字符...',
},
})
const captchaData = ref<{ image : string, status : 'loading' | 'success' | 'fail' }>({
image: '',
status: 'loading',
})
const captchaData = ref<{ image: string, status: 'loading' | 'success' | 'fail' }>({
image: '',
status: 'loading',
})
const form = ref<ICommentForm>({
allowNotification: true,
author: '',
avatar: '',
authorUrl: '',
content: '',
email: '',
postName: props.postName,
captchaCode: undefined,
})
const form = ref<ICommentForm>({
allowNotification: true,
author: '',
avatar: '',
authorUrl: '',
content: '',
email: '',
postName: props.postName,
captchaCode: undefined,
})
const calcTitle = computed(() => {
if (props.isComment)
return props.title || '新增评论'
return `回复用户:${props.title}`
})
const calcTitle = computed(() => {
if (props.isComment)
return props.title || '新增评论'
return `回复用户:${props.title}`
})
function handleResetForm() {
form.value = {
allowNotification: true,
author: '',
avatar: '',
authorUrl: '',
content: '',
email: '',
postName: props.postName,
captchaCode: undefined,
}
}
function handleResetForm() {
form.value = {
allowNotification: true,
author: '',
avatar: '',
authorUrl: '',
content: '',
email: '',
postName: props.postName,
captchaCode: undefined,
}
}
/** 获取评论组件配置(验证码开关) */
async function handleGetConfig() {
try {
const res = await getCommentWidgetConfig()
config.value = deepMerge(config.value as Record<string, unknown>, res.data as Record<string, unknown>) as ICaptchaConfig
if (config.value?.security?.captcha?.anonymousCommentCaptcha) {
handleGetCaptchaImage()
}
}
catch (err) {
console.error('获取验证码配置失败', err)
}
}
/** 获取评论组件配置(验证码开关) */
async function handleGetConfig() {
try {
const res = await getCommentWidgetConfig()
config.value = deepMerge(config.value as Record<string, unknown>, res.data as Record<string, unknown>) as ICaptchaConfig
if (config.value?.security?.captcha?.anonymousCommentCaptcha) {
handleGetCaptchaImage()
}
}
catch (err) {
console.error('获取验证码配置失败', err)
}
}
/** 获取评论验证码图片 */
async function handleGetCaptchaImage() {
captchaData.value.status = 'loading'
try {
const res = await getCommentWidgetCaptcha()
form.value.captchaCode = undefined
captchaData.value.image = res.data as unknown as string
captchaData.value.status = 'success'
}
catch (err) {
console.error('获取验证码失败', err)
captchaData.value.status = 'fail'
captchaData.value.image = ''
}
}
/** 获取评论验证码图片 */
async function handleGetCaptchaImage() {
captchaData.value.status = 'loading'
try {
const res = await getCommentWidgetCaptcha()
form.value.captchaCode = undefined
captchaData.value.image = res.data as unknown as string
captchaData.value.status = 'success'
}
catch (err) {
console.error('获取验证码失败', err)
captchaData.value.status = 'fail'
captchaData.value.image = ''
}
}
/** 初始化访客信息 */
function handleInitVisitor() {
const visitor = uni.getStorageSync('Visitor')
if (!visitor)
return
try {
const v = JSON.parse(visitor)
form.value.author = v.author || ''
form.value.avatar = v.avatar || ''
form.value.email = v.email || ''
form.value.authorUrl = v.authorUrl || ''
}
catch {
// ignore
}
}
/** 初始化访客信息 */
function handleInitVisitor() {
const visitor = uni.getStorageSync('Visitor')
if (!visitor)
return
try {
const v = JSON.parse(visitor)
form.value.author = v.author || ''
form.value.avatar = v.avatar || ''
form.value.email = v.email || ''
form.value.authorUrl = v.authorUrl || ''
}
catch {
// ignore
}
}
/** 保存访客信息 */
function handleSetVisitor() {
setCache('Visitor', {
author: form.value.author,
avatar: form.value.avatar,
email: form.value.email,
authorUrl: form.value.authorUrl,
})
}
/** 保存访客信息 */
function handleSetVisitor() {
setCache('Visitor', {
author: form.value.author,
avatar: form.value.avatar,
email: form.value.email,
authorUrl: form.value.authorUrl,
})
}
/** 提交校验 */
function validateForm() : boolean {
if (!form.value.content.trim()) {
uni.showToast({ icon: 'none', title: '请填写评论内容' })
return false
}
if (!form.value.author.trim()) {
uni.showToast({ icon: 'none', title: '请填写昵称' })
return false
}
if (!form.value.email.trim()) {
uni.showToast({ icon: 'none', title: '请填写邮箱' })
return false
}
if (config.value?.security?.captcha?.anonymousCommentCaptcha && !form.value.captchaCode?.trim()) {
uni.showToast({ icon: 'none', title: '请填写验证码结果!' })
return false
}
return true
}
/** 提交校验 */
function validateForm(): boolean {
if (!form.value.content.trim()) {
uni.showToast({ icon: 'none', title: '请填写评论内容' })
return false
}
if (!form.value.author.trim()) {
uni.showToast({ icon: 'none', title: '请填写昵称' })
return false
}
if (!form.value.email.trim()) {
uni.showToast({ icon: 'none', title: '请填写邮箱' })
return false
}
if (config.value?.security?.captcha?.anonymousCommentCaptcha && !form.value.captchaCode?.trim()) {
uni.showToast({ icon: 'none', title: '请填写验证码结果!' })
return false
}
return true
}
async function handleHandle() {
if (!validateForm())
return
async function handleHandle() {
if (!validateForm())
return
uni.showLoading({ title: '正在提交...' })
uni.showLoading({ title: '正在提交...' })
try {
if (props.isComment) {
// 新增评论
await addPostComment({
allowNotification: form.value.allowNotification,
raw: form.value.content,
content: form.value.content,
owner: {
displayName: form.value.author,
email: form.value.email,
website: form.value.authorUrl,
},
subjectRef: {
group: 'content.halo.run',
kind: 'Post',
name: form.value.postName,
version: 'v1alpha1',
},
captchaCode: config.value?.security?.captcha?.anonymousCommentCaptcha ? form.value.captchaCode : undefined,
})
uni.showToast({ icon: 'none', title: '评论成功,可能需要审核!' })
}
else {
// 回复评论
await addPostCommentReply(form.value.postName, {
allowNotification: form.value.allowNotification,
raw: form.value.content,
content: form.value.content,
owner: {
displayName: form.value.author,
email: form.value.email,
website: form.value.authorUrl,
},
captchaCode: config.value?.security?.captcha?.anonymousCommentCaptcha ? form.value.captchaCode : undefined,
})
uni.showToast({ icon: 'none', title: '回复成功,可能需要审核!' })
}
try {
if (props.isComment) {
// 新增评论
await addPostComment({
raw: form.value.content,
content: form.value.content,
owner: {
displayName: form.value.author,
email: form.value.email,
website: form.value.authorUrl,
},
subjectRef: {
group: 'content.halo.run',
kind: 'Post',
name: form.value.postName,
version: 'v1alpha1',
},
captchaCode: config.value?.security?.captcha?.anonymousCommentCaptcha ? form.value.captchaCode : undefined,
})
uni.showToast({ icon: 'none', title: '评论成功,可能需要审核!' })
}
else {
// 回复评论
await addPostCommentReply(form.value.postName, {
raw: form.value.content,
content: form.value.content,
owner: {
displayName: form.value.author,
email: form.value.email,
website: form.value.authorUrl,
},
captchaCode: config.value?.security?.captcha?.anonymousCommentCaptcha ? form.value.captchaCode : undefined,
})
uni.showToast({ icon: 'none', title: '回复成功,可能需要审核!' })
}
handleSetVisitor()
handleClose(true)
handleResetForm()
}
catch (err : any) {
const error = err as UniHaloError
if (config.value?.security?.captcha?.anonymousCommentCaptcha) {
captchaData.value.status = 'success'
form.value.captchaCode = undefined
if(error?.data?.captcha){
captchaData.value.image = error?.data?.captcha
}else{
handleGetCaptchaImage()
}
}
uni.showToast({ icon: 'none', title: error?.data?.detail ?? '提交失败' })
}
finally {
uni.hideLoading()
}
}
handleSetVisitor()
handleClose(true)
handleResetForm()
}
catch (err) {
console.error('评论提交失败', err)
if (config.value?.security?.captcha?.anonymousCommentCaptcha) {
captchaData.value.status = 'success'
form.value.captchaCode = undefined
handleGetCaptchaImage()
}
uni.showToast({ icon: 'none', title: '提交失败' })
}
finally {
uni.hideLoading()
}
}
function handleOnChange(isOpen : boolean) {
isShow.value = isOpen
if (!isOpen) {
emit('on-close', { isSubmit: false, refresh: false })
}
}
function handleOnChange(isOpen: boolean) {
isShow.value = isOpen
if (!isOpen) {
emit('on-close', { isSubmit: false, refresh: false })
}
}
function handleClose(refresh = false) {
isShow.value = false
emit('on-close', { isSubmit: true, refresh })
}
function handleClose(refresh = false) {
isShow.value = false
emit('on-close', { isSubmit: true, refresh })
}
watch(() => props.show, (newVal) => {
if (!newVal)
return
isShow.value = true
handleResetForm()
form.value.postName = props.postName
handleGetConfig()
handleInitVisitor()
})
watch(() => props.show, (newVal) => {
if (!newVal) {
return
}
isShow.value = true
handleResetForm()
form.value.postName = props.postName
handleGetConfig()
handleInitVisitor()
}, {
immediate: true
})
</script>
<template>
<wd-popup v-model="isShow" position="bottom" custom-style="border-radius:12rpx 12rpx 0 0;" @close="handleOnChange(false)">
<view class="uh-comment-modal box-border max-h-[71vh] overflow-y-auto p-3">
<view class="title my-6 text-center text-[32rpx] font-bold">
{{ calcTitle }}
</view>
<wd-popup v-model="isShow" position="bottom" :z-index="100" closable custom-style="border-radius:12rpx 12rpx 0 0;"
@close="handleOnChange(false)">
<view class="uh-comment-modal box-border p-3">
<view class="title text-center text-md font-bold">
{{ calcTitle }}
</view>
<view class="form">
<view class="form-item mb-5 flex items-center">
<textarea
v-model="form.content"
class="content-input box-border w-full rounded-xl bg-[#f5f5f5] p-5 text-[26rpx]"
:placeholder="config.editor?.placeholder || '请输入内容,不超过200字符...'"
:maxlength="200"
style="height: 200rpx;"
/>
</view>
<view class="form mt-6 max-h-[70vh] overflow-y-auto">
<view class="form-item mb-5 flex items-center">
<textarea v-model="form.content"
class="content-input box-border w-full rounded-xl bg-[#f5f5f5] p-5 text-[26rpx]"
:placeholder="config.editor?.placeholder || '请输入内容,不超过200字符...'" :maxlength="200"
style="height: 200rpx;" />
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的昵称</text>
<input v-model="form.author" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="请输入您的昵称...">
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的昵称</text>
<input v-model="form.author"
class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]"
placeholder="请输入您的昵称...">
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的邮箱</text>
<input v-model="form.email" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="请输入您的邮箱...">
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的邮箱</text>
<input v-model="form.email" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]"
placeholder="请输入您的邮箱...">
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的网站</text>
<input v-model="form.authorUrl" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="请输入您的网址...">
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的网站</text>
<input v-model="form.authorUrl"
class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]"
placeholder="请输入您的网址...">
</view>
<!-- 匿名评论验证码 -->
<view v-if="config?.security?.captcha?.anonymousCommentCaptcha" class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">验证码</text>
<view class="captcha-row flex flex-1 items-center gap-4">
<input v-model="form.captchaCode" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="请输入验证码结果">
<view class="captcha-wrapper h-[68rpx] w-[200rpx] flex shrink-0 items-center justify-center">
<text v-if="captchaData.status === 'loading'" class="captcha-tip text-[24rpx] text-[#999]">获取中...</text>
<text v-else-if="captchaData.status === 'fail'" class="captcha-tip text-[24rpx] text-[#f56c6c]" @click="handleGetCaptchaImage()">请重试</text>
<image v-else :src="captchaData.image" class="captcha-img h-full w-full" mode="aspectFit" @click="handleGetCaptchaImage()" />
</view>
</view>
</view>
<!-- 匿名评论验证码 -->
<view v-if="config?.security?.captcha?.anonymousCommentCaptcha"
class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">验证码</text>
<view class="captcha-row flex flex-1 items-center gap-4">
<input v-model="form.captchaCode"
class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]"
placeholder="请输入验证码结果">
<view class="captcha-wrapper h-[68rpx] w-[200rpx] flex shrink-0 items-center justify-center">
<text v-if="captchaData.status === 'loading'"
class="captcha-tip text-[24rpx] text-[#999]">获取中...</text>
<text v-else-if="captchaData.status === 'fail'"
class="captcha-tip text-[24rpx] text-[#f56c6c]"
@click="handleGetCaptchaImage()">请重试</text>
<image v-else :src="captchaData.image" class="captcha-img h-full w-full" mode="aspectFit"
@click="handleGetCaptchaImage()" />
</view>
</view>
</view>
<view class="submit-btn my-6">
<wd-button type="primary" block size="medium" @click="handleHandle">
提交
</wd-button>
</view>
</view>
</view>
</wd-popup>
<view class="submit-btn my-6">
<wd-button type="primary" block size="medium" @click="handleHandle">
提交
</wd-button>
</view>
</view>
</view>
</wd-popup>
</template>
<style scoped lang="scss">
.uh-comment-modal {
.content-input {
min-height: 200rpx;
}
}
</style>
.uh-comment-modal {
.content-input {
min-height: 200rpx;
}
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { DataLoadingStatusEnum } from '@/hooks/useDataLoadingStatus'
interface IProps {
loadingStatus?: DataLoadingStatusEnum
}
const props = withDefaults(defineProps<IProps>(), {
loadingStatus: DataLoadingStatusEnum.Loading,
})
const emit = defineEmits(['refresh'])
function handleRefresh() {
emit('refresh')
}
</script>
<template>
<view class="min-h-screen w-full flex items-center justify-center">
<!-- 加载中 -->
<wd-loading v-if="props.loadingStatus === DataLoadingStatusEnum.Loading" :size="60">
<view>加载中...</view>
</wd-loading>
<!-- 加载错误 -->
<wd-empty v-else-if="props.loadingStatus === DataLoadingStatusEnum.Error" :icon-size="60" icon="no-result">
<view>加载失败</view>
<wd-button @click="handleRefresh">
刷新试试
</wd-button>
</wd-empty>
<!-- 加载成功 -->
<wd-empty v-else-if="props.loadingStatus === DataLoadingStatusEnum.Empty" :icon-size="60" icon="success">
<view>无数据</view>
<wd-button @click="handleRefresh">
刷新试试
</wd-button>
</wd-empty>
</view>
</template>
+24
View File
@@ -0,0 +1,24 @@
export enum DataLoadingStatusEnum {
Loading = 'loading',
Error = 'error',
Empty = 'empty',
Success = 'success',
}
export function useDataLoadingStatus() {
const loadingStatus = ref<DataLoadingStatusEnum>(DataLoadingStatusEnum.Loading)
function resetLoadingStatus() {
loadingStatus.value = DataLoadingStatusEnum.Loading
}
function updateLoadingStatus(newStatus: DataLoadingStatusEnum) {
loadingStatus.value = newStatus
}
return {
loadingStatus,
resetLoadingStatus,
updateLoadingStatus,
}
}
+111 -106
View File
@@ -1,128 +1,133 @@
import type { uniappRequestAdapter } from '@alova/adapter-uniapp';
import type { IResponse } from './types';
import AdapterUniapp from '@alova/adapter-uniapp';
import { createAlova } from 'alova';
import { createServerTokenAuthentication } from 'alova/client';
import VueHook from 'alova/vue';
import { toLoginPage } from '@/utils/toLoginPage';
import { ContentTypeEnum, RequestFrom, ResultEnum, ShowMessage } from './tools/enum';
import { saveCommentCookies } from './tools/commentCookies';
import { handleCategoryPasswordError } from './tools/categoryPassword';
import type { uniappRequestAdapter } from '@alova/adapter-uniapp'
import type { IResponse } from './types'
import AdapterUniapp from '@alova/adapter-uniapp'
import { createAlova } from 'alova'
import { createServerTokenAuthentication } from 'alova/client'
import VueHook from 'alova/vue'
import { toLoginPage } from '@/utils/toLoginPage'
import { ContentTypeEnum, RequestFrom, ResultEnum, ShowMessage } from './tools/enum'
import { saveCommentCookies } from './tools/commentCookies'
import { handleCategoryPasswordError } from './tools/categoryPassword'
import { UniHaloError } from './tools/exception'
// 配置动态Tag
export const API_DOMAINS = {
DEFAULT: import.meta.env.VITE_SERVER_BASEURL,
SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY
};
DEFAULT: import.meta.env.VITE_SERVER_BASEURL,
SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY,
}
/**
* 创建请求实例
*/
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<typeof VueHook, typeof uniappRequestAdapter>({
// 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛
refreshTokenOnError: {
isExpired: (error) => {
return error.response?.status === ResultEnum.Unauthorized;
},
handler: async () => {
try {
// await authLogin();
} catch (error) {
// 切换到登录页
toLoginPage({ mode: 'reLaunch' });
throw error;
}
}
}
});
// 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛
refreshTokenOnError: {
isExpired: (error) => {
return error.response?.status === ResultEnum.Unauthorized
},
handler: async () => {
try {
// await authLogin();
}
catch (error) {
// 切换到登录页
toLoginPage({ mode: 'reLaunch' })
throw error
}
},
},
})
/**
* alova 请求实例
*/
const alovaInstance = createAlova({
baseURL: API_DOMAINS.DEFAULT,
...AdapterUniapp(),
timeout: 5000,
statesHook: VueHook,
baseURL: API_DOMAINS.DEFAULT,
...AdapterUniapp(),
timeout: 5000,
statesHook: VueHook,
beforeRequest: onAuthRequired((method) => {
// 设置默认 Content-Type
method.config.headers = {
ContentType: ContentTypeEnum.JSON,
Accept: 'application/json, text/plain, */*',
...method.config.headers
};
beforeRequest: onAuthRequired((method) => {
// 设置默认 Content-Type
method.config.headers = {
ContentType: ContentTypeEnum.JSON,
Accept: 'application/json, text/plain, */*',
...method.config.headers,
}
const { config } = method;
const ignoreAuth = !config.meta?.ignoreAuth;
console.log('ignoreAuth===>', ignoreAuth);
// 处理认证信息 自行处理认证问题
if (ignoreAuth) {
const token = 'getToken()';
if (!token) {
throw new Error('[请求错误]:未登录');
}
// method.config.headers.token = token;
}
const { config } = method
const ignoreAuth = !config.meta?.ignoreAuth
console.log('ignoreAuth===>', ignoreAuth)
// 处理认证信息 自行处理认证问题
if (ignoreAuth) {
const token = 'getToken()'
if (!token) {
throw new Error('[请求错误]:未登录')
}
// method.config.headers.token = token;
}
if (config.meta?.personalToken) {
config.headers['Authorization'] = `Bearer ${config.meta.personalToken}`;
}
if (config.meta?.personalToken) {
config.headers.Authorization = `Bearer ${config.meta.personalToken}`
}
// 处理动态域名
if (config.meta?.domain) {
method.baseURL = config.meta.domain;
console.log('当前域名', method.baseURL);
}
}),
// 处理动态域名
if (config.meta?.domain) {
method.baseURL = config.meta.domain
console.log('当前域名', method.baseURL)
}
}),
responded: onResponseRefreshToken({
onSuccess: (response, method) => {
console.log('onResponseRefreshToken response===>', response)
console.log('onResponseRefreshToken method===>', method)
const { config } = method
const { requestType } = config
const { statusCode, data: rawData, header } = response as UniNamespace.RequestSuccessCallbackResult
responded: onResponseRefreshToken((response, method) => {
console.log('response===>', response);
console.log('method===>', method);
const { config } = method;
const { requestType } = config;
const { statusCode, data: rawData, errMsg, header } = response as UniNamespace.RequestSuccessCallbackResult;
// 处理特殊请求类型(上传/下载)
if (requestType === 'upload' || requestType === 'download') {
return response
}
// 处理特殊请求类型(上传/下载)
if (requestType === 'upload' || requestType === 'download') {
return response;
}
// 保存评论验证码 cookie
saveCommentCookies(method.url, header as Record<string, string> | undefined)
// 保存评论验证码 cookie
saveCommentCookies(method.url, header as Record<string, string> | undefined);
// 分类加密密码处理(Halo 私密分类 401/403
// 注意:此处需在 alova 统一 401 刷新之前消费,避免分类密码请求被误判为登录过期
// const isCategoryConsumed = handleCategoryPasswordError(statusCode, method.url, rawData as { status?: number });
// if (isCategoryConsumed) {
// throw new Error('分类访问受限');
// }
if (statusCode !== ResultEnum.Success200) {
const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`
throw new UniHaloError({
message: errorMessage,
data: rawData,
code: statusCode,
})
}
// 分类加密密码处理(Halo 私密分类 401/403
// 注意:此处需在 alova 统一 401 刷新之前消费,避免分类密码请求被误判为登录过期
const isCategoryConsumed = handleCategoryPasswordError(statusCode, method.url, rawData as { status?: number });
if (isCategoryConsumed) {
throw new Error('分类访问受限');
}
// 归一化响应结构:Halo 来源的原始数据统一封装为 { code, data, message },
// 与标准接口走同一条解析路径,保证响应模型一致
let responseData: IResponse
if (config.meta?.requestFrom === RequestFrom.Halo) {
responseData = {
code: statusCode,
data: rawData,
message: '请求成功',
}
}
else {
responseData = rawData as IResponse
}
return responseData
},
onError: (error, method) => {
console.error('onResponseRefreshToken error===>', error)
console.error('onResponseRefreshToken method===>', method)
},
}),
})
// 处理 HTTP 状态码错误
if (statusCode !== 200) {
const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`;
console.error('errorMessage===>', errorMessage);
uni.showToast({
title: errorMessage,
icon: 'error'
});
throw new Error(`${errorMessage}${errMsg}`);
}
// 归一化响应结构:Halo 来源的原始数据统一封装为 { code, data, message },
// 与标准接口走同一条解析路径,保证响应模型一致
let responseData: IResponse;
if (config.meta?.requestFrom === RequestFrom.Halo) {
responseData = {
code: ResultEnum.Success200,
data: rawData,
message: '请求成功'
};
} else {
responseData = rawData as IResponse;
}
return responseData;
})
});
export const http = alovaInstance;
export const http = alovaInstance
+2 -8
View File
@@ -14,24 +14,18 @@ const httpInterceptor = {
// 如果您使用了alova,则请把下面的代码放开注释
// alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded
// return options
// 非 alova 请求,正常执行
// 接口请求支持通过 query 参数配置 queryString
if (options.query) {
const queryStr = stringifyQuery(options.query)
// const queryStr = qs.stringify(options.query, {
// allowDots: true,
// encodeValuesOnly: true,
// skipNulls: true,
// encode: true,
// arrayFormat: 'repeat'
// });
if (options.url.includes('?')) {
options.url += `&${queryStr}`;
} else {
options.url += `?${queryStr}`;
}
}
// 非 http 开头需拼接地址
if (!options.url.startsWith('http')) {
// #ifdef H5
+22
View File
@@ -0,0 +1,22 @@
import type { IResponse } from '../types';
export class UniHaloError extends Error {
public readonly code: number;
public readonly data?: any;
/**
* @param message 错误提示
* @param code 业务码
* @param data 附加对象数据
*/
constructor(errData: IResponse) {
super(errData.message);
// 必须设置nameinstanceof、日志打印才正常
this.name = this.constructor.name;
this.code = errData.code;
this.data = errData.data;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, UniHaloError);
}
}
}
+1
View File
@@ -3,6 +3,7 @@
*/
export type CustomRequestOptions = UniApp.RequestOptions & {
query?: Record<string, any>
params?: Record<string, any>
/** 出错时是否隐藏错误提示 */
hideErrorToast?: boolean
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
+131 -107
View File
@@ -1,12 +1,6 @@
<script lang="ts" setup>
/**
* 文章详情页(源自旧项目 pagesA/article-detail/article-detail.vue,新建复刻)
* 功能:文章头部(标题/作者/封面/统计) + 分类标签 + mp-html 内容渲染 + 受限阅读 + 点赞 + 评论
* TODO: 投票(article-vote)、豆瓣(article-douban)、分享海报(liu-poster)待阶段2/3 补充
*/
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getPostByName, getPostCommentReplyList, postTrackersCounter, submitUpvote } from '@/api/halo'
import { createVerificationCode, requestRestrictReadCheck } from '@/api/uni-halo'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
@@ -18,6 +12,7 @@ import { checkPostRestrictRead, copyToClipboard, getRestrictReadTypeName, getSho
import { getDomainOnly } from '@/utils/urlParams'
import { markdownConfig } from '@/config/markdown'
import type { IComment, IPost } from '@/api/types/halo'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
definePage({
style: {
@@ -28,11 +23,11 @@ definePage({
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const haloConfigs = computed(() => appConfigStore.configs)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryName = ref('')
const result = ref<IPost & {
_voteIds?: string[]
@@ -137,7 +132,7 @@ function handleGetOpenid() {
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
updateLoadingStatus(DataLoadingStatusEnum.Loading)
try {
const res = await getPostByName(queryName.value)
const tempResult = res.data as typeof result.value
@@ -160,12 +155,12 @@ async function handleGetData() {
}
result.value = tempResult
uni.setNavigationBarTitle({ title: '文章详情' })
loading.value = 'success'
updateLoadingStatus(DataLoadingStatusEnum.Success)
handleTrackersCounter()
}
catch (err) {
console.error('获取文章失败', err)
loading.value = 'error'
updateLoadingStatus(DataLoadingStatusEnum.Error)
}
finally {
uni.hideLoading()
@@ -175,8 +170,9 @@ async function handleGetData() {
/** 访问计数埋点 */
async function handleTrackersCounter() {
if (!result.value)
if (!result.value) {
return
}
const winInfo = uni.getWindowInfo()
const appBaseInfo = uni.getAppBaseInfo()
const baseUrl = import.meta.env.VITE_SERVER_BASEURL || ''
@@ -310,10 +306,14 @@ async function getVerificationCode() {
/* ---------------- 评论 ---------------- */
function handleToComment() {
if (!result.value)
return
if (!calcIsShowComment.value)
return
console.log('calcIsShowComment.value',calcIsShowComment.value)
console.log('result.value',result.value)
if (!result.value){
return
}
if (!calcIsShowComment.value){
return
}
if (!result.value.spec.allowComment) {
uni.showToast({ icon: 'none', title: '文章已开启禁止评论!' })
return
@@ -461,15 +461,15 @@ const globalAppSettings = computed(() => settingStore.settings)
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-[120rpx]" style="background-color: #fafafd;">
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-safe" style="background-color: #fafafd;">
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap bg-white p-3">
<wd-skeleton :row="4" :animated="true" />
<view v-if="loadingStatus !== 'success'" class="box-border p-4">
<uh-data-loading :loading-status="loadingStatus" @refresh="handleGetData()" />
</view>
<block v-else>
<!-- 顶部信息 -->
<view class="head mx-6 mt-6 flex flex-col items-center rounded-xl bg-white px-6 py-9 shadow-sm">
<view class="head flex flex-col items-center rounded-xl bg-white p-4 shadow-sm">
<view class="title text-center text-[36rpx] font-semibold">
{{ result?.spec.title }}
</view>
@@ -481,14 +481,16 @@ const globalAppSettings = computed(() => settingStore.settings)
<view v-if="result?.spec.cover" class="cover mt-6 h-[280rpx] w-full">
<image
class="cover-img h-full w-full rounded-xl"
mode="aspectFill"
class="cover-img h-full w-full rounded-xl" mode="aspectFill"
:src="calcUrl(result.spec.cover)"
@click="handlePreview(0, [{ url: calcUrl(result.spec.cover) }])"
/>
</view>
<view class="count mt-6 flex justify-between" :class="{ 'no-thumbnail border-t-2 border-[#f2f2f2] pt-3': !result?.spec.cover }">
<view
class="count mt-6 flex justify-between"
:class="{ 'no-thumbnail border-t-2 border-[#f2f2f2] pt-3': !result?.spec.cover }"
>
<view class="count-item flex flex-1 items-end justify-center text-[#666]">
<text class="value text-[32rpx]">{{ result?.stats?.visit ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">阅读</text>
@@ -497,7 +499,10 @@ const globalAppSettings = computed(() => settingStore.settings)
<text class="value text-[32rpx]">{{ result?.stats?.upvote ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">喜欢</text>
</view>
<view v-if="calcIsShowComment" class="count-item flex flex-1 items-end justify-center text-[#666]">
<view
v-if="calcIsShowComment"
class="count-item flex flex-1 items-end justify-center text-[#666]"
>
<text class="value text-[32rpx]">{{ result?.stats?.comment ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">评论</text>
</view>
@@ -510,76 +515,89 @@ const globalAppSettings = computed(() => settingStore.settings)
</view>
<!-- 分类标签 -->
<view class="category mx-6 mt-6 rounded-xl bg-white p-6 text-[28rpx] shadow-sm">
<view class="category rounded-xl bg-white p-4 text-xs">
<view class="category-type leading-[55rpx]">
<text class="category-label font-bold">分类</text>
<text v-if="!result?.categories?.length" class="category-tag is-empty rounded-md bg-[#607d8b] px-1.5 py-0.5 text-[24rpx] text-white">未选择分类</text>
<text v-for="(item, index) in result?.categories" v-else :key="index" class="category-tag mr-3 rounded-md bg-[#5bb8fa] px-1.5 py-0.5 text-[24rpx] text-white" @click="handleToCate(item)">
{{ item.spec.displayName }}
<text
v-if="!result?.categories?.length"
class="text-xs"
>
未选择分类
</text>
<template v-else>
<text
v-for="(item, index) in result?.categories" :key="index"
class="text-xs"
@click="handleToCate(item)"
>
{{ item.spec.displayName }}
</text>
</template>
</view>
<view class="category-type leading-[55rpx]">
<text class="category-label font-bold">标签</text>
<text v-if="!result?.tags?.length" class="category-tag is-empty rounded-md bg-[#607d8b] px-1.5 py-0.5 text-[24rpx] text-white">未选择标签</text>
<text
v-for="(item, index) in result?.tags"
v-else
:key="index"
class="category-tag mr-3 rounded-md px-1.5 py-0.5 text-[24rpx] text-white"
:style="{ backgroundColor: item.spec.color || '#5bb8fa' }"
@click="handleToTag(item)"
v-if="!result?.tags?.length"
class="text-xs"
>
{{ item.spec.displayName }}
未选择标签
</text>
<template v-else>
<text
v-for="(item, index) in result?.tags" :key="index"
class="text-xs"
@click="handleToTag(item)"
>
{{ item.spec.displayName }}
</text>
</template>
</view>
<view v-if="originalURL" class="category-type flex leading-[55rpx]">
<view class="original-url-left w-[84rpx] shrink-0 font-bold">
原文
</view>
<view class="original-url-right inline-flex flex-1 items-center">
<text class="original-url-link inline-block w-[410rpx] overflow-hidden text-ellipsis whitespace-nowrap text-[#909399]" @click.stop="handleToOriginal(originalURL)">{{ originalURL }}</text>
<text class="original-url-btn flex-1 text-right text-[#03a9f4]" @click.stop="handleToOriginal(originalURL)">阅读原文</text>
<text
class="original-url-link inline-block w-[410rpx] overflow-hidden text-ellipsis whitespace-nowrap text-[#909399]"
@click.stop="handleToOriginal(originalURL)"
>
{{ originalURL }}
</text>
<text
class="original-url-btn flex-1 text-right text-[#03a9f4]"
@click.stop="handleToOriginal(originalURL)"
>
阅读原文
</text>
</view>
</view>
</view>
<!-- 内容区域 -->
<view class="content mx-6 mt-6">
<view class="markdown-wrap overflow-hidden rounded-xl bg-white p-1.5 shadow-sm">
<view class="content">
<view class="markdown-wrap overflow-hidden rounded-xl bg-white p-1.5">
<!-- 受限阅读 -->
<template v-if="checkPostRestrictRead(result!)">
<view v-if="showContentArr.length === 0">
<uh-restrict-read-skeleton
:loading="true"
:lines="3"
:loading="true" :lines="3"
:tip-text="`此处内容已隐藏,「${getRestrictReadTypeName(result!)}可见」`"
:button-text="getRestrictReadTypeName(result!)"
button-color="#1890ff"
:button-text="getRestrictReadTypeName(result!)" button-color="#1890ff"
@refresh="readMore"
/>
</view>
<view v-for="(showContent, showContentIndex) in showContentArr" v-else :key="showContentIndex">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="showContent"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
class="evan-markdown" lazy-load :domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif" scroll-table selectable
:tag-style="markdownConfig.tagStyle" :container-style="markdownConfig.containStyle"
:content="showContent" :markdown="true" :show-line-number="true"
:show-language-name="true" copy-by-long-press
/>
<uh-restrict-read-skeleton
:loading="true"
:lines="3"
:loading="true" :lines="3"
:tip-text="`此处内容已隐藏,「${getRestrictReadTypeName(result!)}可见」`"
:button-text="getRestrictReadTypeName(result!)"
button-color="#1890ff"
:button-text="getRestrictReadTypeName(result!)" button-color="#1890ff"
@refresh="readMore"
/>
</view>
@@ -588,75 +606,85 @@ const globalAppSettings = computed(() => settingStore.settings)
<!-- 正常渲染 -->
<template v-else>
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="result?.content?.raw || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
class="evan-markdown" lazy-load :domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif" scroll-table selectable
:tag-style="markdownConfig.tagStyle" :container-style="markdownConfig.containStyle"
:content="result?.content?.raw || ''" :markdown="true" :show-line-number="true"
:show-language-name="true" copy-by-long-press
/>
</template>
</view>
<!-- 版权声明 -->
<view v-if="postDetailConfig?.copyrightEnabled" class="card-wrap mt-6 rounded-xl bg-white p-6 shadow-sm">
<view
v-if="postDetailConfig?.copyrightEnabled"
class="card-wrap rounded-xl bg-white p-4"
>
<view class="card-title relative box-border pl-6 text-[30rpx] font-bold">
<text class="absolute left-0 top-2 h-[26rpx] w-2 rounded-lg bg-[#03aefc]" />
<text class="absolute left-0 top-1 h-[26rpx] w-1 rounded-lg bg-[#03aefc]" />
版权声明
</view>
<view class="copyright-content mt-3 rounded-xl bg-[#fafafa] px-6 py-1.5">
<view v-if="postDetailConfig.copyrightAuthor" class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]">
<view
v-if="postDetailConfig.copyrightAuthor"
class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]"
>
版权归属{{ postDetailConfig.copyrightAuthor }}
</view>
<view v-if="postDetailConfig.copyrightDesc" class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]">
<view
v-if="postDetailConfig.copyrightDesc"
class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]"
>
版权说明{{ postDetailConfig.copyrightDesc }}
</view>
<view v-if="postDetailConfig.copyrightViolation" class="copyright-text text-[26rpx] text-[#f56c6c] leading-[1.7]">
<view
v-if="postDetailConfig.copyrightViolation"
class="copyright-text text-[26rpx] text-[#f56c6c] leading-[1.7]"
>
侵权处理{{ postDetailConfig.copyrightViolation }}
</view>
</view>
</view>
<!-- 评论区域 -->
<view v-if="calcIsShowComment && result" class="card-wrap mt-6 rounded-xl bg-white p-6 shadow-sm">
<view v-if="calcIsShowComment && result" class="card-wrap rounded-xl bg-white p-4">
<uh-comment-list
:disallow-comment="!result.spec.allowComment"
:post-name="result.metadata.name"
:post="result"
@on-comment="handleOnComment"
@on-comment-detail="handleOnShowCommentDetail"
:disallow-comment="!result.spec.allowComment" :post-name="result.metadata.name"
:post="result" @on-comment="handleOnComment" @on-comment-detail="handleOnShowCommentDetail"
/>
</view>
</view>
<!-- 悬浮操作 -->
<view class="flot-buttons fixed bottom-[100rpx] right-8 z-999 flex flex-col gap-1.5">
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<view class="flot-buttons fixed bottom-[100rpx] right-4 z-99 flex flex-col gap-1.5">
<view
class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm"
@click="handleToTopPage()"
>
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" :class="{ active: hasUpvoted() }" @click="handleDoLikes">
<wd-icon :name="hasUpvoted() ? 'heart' : 'heart-outline'" size="20px" :color="hasUpvoted() ? '#f44336' : '#03a9f4'" />
<view
class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm"
:class="{ active: hasUpvoted() }" @click="handleDoLikes"
>
<wd-icon
name="heart" size="20px"
:color="hasUpvoted() ? '#f44336' : '#03a9f4'"
/>
</view>
<view v-if="calcIsShowComment" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToComment">
<wd-icon name="chat" size="20px" color="#4caf50" />
<view
v-if="calcIsShowComment"
class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm"
@click="handleToComment()"
>
<wd-icon name="message" size="20px" color="#4caf50" />
</view>
</view>
</block>
<!-- 密码弹窗 -->
<wd-dialog
v-model="passwordModal.show"
title="验证提示"
:show-cancel="true"
show-confirm-button
confirm-text="确定"
v-model="passwordModal.show" title="验证提示" :show-cancel="true" show-confirm-button confirm-text="确定"
@confirm="restrictReadCheck"
>
<view class="modal-body py-4">
@@ -666,32 +694,28 @@ const globalAppSettings = computed(() => settingStore.settings)
<!-- 验证码弹窗 -->
<wd-dialog
v-model="verificationCodeModal.show"
title="验证提示"
:show-cancel="true"
confirm-text="确定"
v-model="verificationCodeModal.show" title="验证提示" :show-cancel="true" confirm-text="确定"
@confirm="restrictReadCheck"
>
<view class="modal-body py-4">
<image v-if="verificationCodeModal.imgUrl" :src="verificationCodeModal.imgUrl" class="modal-code-img mb-4 h-[200rpx] w-full" mode="aspectFit" />
<image
v-if="verificationCodeModal.imgUrl" :src="verificationCodeModal.imgUrl"
class="modal-code-img mb-4 h-[200rpx] w-full" mode="aspectFit"
/>
<wd-input v-model="restrictReadInputCode" placeholder="请输入验证码" class="mt-2" />
</view>
</wd-dialog>
<!-- 评论弹窗 -->
<uh-comment-modal
v-if="commentModal.show"
:show="commentModal.show"
:is-comment="commentModal.isComment"
:title="commentModal.title"
:post-name="commentModal.postName"
@on-close="handleOnCommentModalClose"
v-if="commentModal.show" :show="commentModal.show" :is-comment="commentModal.isComment"
:title="commentModal.title" :post-name="commentModal.postName" @on-close="handleOnCommentModalClose"
/>
</view>
</template>
<style scoped lang="scss">
.app-page {
.app-page {
display: flex;
flex-direction: column;
}
@@ -4,12 +4,13 @@
* 功能:关键词搜索文章/瞬间,结果列表展示
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { getPostListByKeyword } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import { markdownConfig } from '@/config/markdown'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { debounce } from '@/utils/debounce'
definePage({
style: {
@@ -42,6 +43,24 @@ 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
}
/* ---------------- 搜索 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value)
@@ -64,6 +83,7 @@ async function handleGetData() {
}
function handleOnSearch() {
resetAniWaitIndex()
if (!queryParams.value.keyword) {
dataList.value = []
loading.value = 'success'
@@ -73,6 +93,11 @@ function handleOnSearch() {
}
}
/** 实时搜索:输入防抖 400ms 后触发(对应旧版 tm-search 的 @input) */
const handleOnInput = debounce(() => {
handleOnSearch()
}, 400)
function isArticle(item: { type?: string }): boolean {
return item.type === 'post.content.halo.run'
}
@@ -106,14 +131,23 @@ function handleToTopPage(duration = 500) {
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
resetAniWaitIndex()
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
// (),
if (!queryParams.value.keyword) {
loading.value = 'success'
}
else {
handleGetData()
}
})
onShow(() => {
resetAniWaitIndex()
})
onPullDownRefresh(() => {
@@ -143,6 +177,7 @@ onPullDownRefresh(() => {
class="search-field flex-1 text-[26rpx]"
placeholder="搜索内容..."
confirm-type="search"
@input="handleOnInput"
@confirm="handleOnSearch"
>
<view v-if="queryParams.keyword" class="clear-btn flex items-center" @click="queryParams.keyword = ''; handleOnSearch()">
@@ -168,7 +203,13 @@ onPullDownRefresh(() => {
</view>
<block v-else>
<view v-for="(item, index) in dataList" :key="index" class="article-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm" @click="handleToDetail(item)">
<view
v-for="(item, index) in dataList"
:key="index"
class="article-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm fade-up"
:style="{ animationDelay: `${calcAniWait(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]'">
{{ isArticle(item) ? '文章' : '瞬间' }}
@@ -208,4 +249,20 @@ onPullDownRefresh(() => {
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
/* 列表项入场动画(对应旧版 tm-translate fadeUp) */
.fade-up {
animation: fade-up 0.4s ease-out both;
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(24rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
</style>
+1 -1
View File
@@ -28,7 +28,7 @@ const articleDetailPath = '/pages-blog/article-detail/article-detail'
// 本地开发快速跳转页面,发布请置为 false
const DEV_MODE = false
const DEV_TO_TYPE = 'page' as 'page' | 'tabbar'
const DEV_TO_PATH = '/pages-blog/data-visual/data-visual'
const DEV_TO_PATH = articleDetailPath + '?name=01a057b2-3200-74af-8afe-28a054092e82'
/* ---------------- 状态 ---------------- */
const appConfigStore = useAppConfigStore()
+13 -11
View File
@@ -51,7 +51,8 @@ async function handleGetCategory() {
}
try {
const res = await getPhotoGroupList({ page: 1, size: 0 })
category.value.list = (res.data.items || [])
console.log('分类数据',res.data)
category.value.list = (res.data || [])
.map(item => ({
name: item.metadata.name,
displayName: item.spec.displayName,
@@ -134,10 +135,11 @@ function handleGetDataByCategory(index: number) {
handleGetData(true)
}
function handleOnCategoryChange(e: { detail: { current: number } }) {
function handleOnCategoryChange(e:{index:number,name:number}) {
console.log('切换分类', e)
if (lock.value)
return
handleGetDataByCategory(e.detail.current)
handleGetDataByCategory(e.index)
}
/* ---------------- 图片预览 ---------------- */
@@ -207,17 +209,17 @@ onReachBottom(() => {
/>
<template v-else>
<!-- 顶部切换 -->
<view v-if="category.list.length > 0" class="category-tabs fixed inset-x-0 top-0 z-6 bg-white">
<wd-tabs
<wd-tabs
v-if="category.list.length > 0"
v-model="category.activeIndex"
:tabs="category.list.map(item => ({ title: item.displayName }))"
align="left"
sticky
:offset-top="0"
@change="handleOnCategoryChange"
/>
</view>
<!-- 占位区域 -->
<view v-if="category.list.length > 0" class="h-[90rpx] w-screen" />
>
<wd-tab v-for="cate in category.list" :key="cate.displayName" :title="cate.displayName"></wd-tab>
</wd-tabs>
<!-- 骨架屏 -->
<view v-if="loading === 'loading'" class="loading-wrap box-border p-3">
<wd-skeleton :row="4" :animated="true" />
+11 -71
View File
@@ -18,6 +18,7 @@ definePage({
navigationBarTitleText: '首页',
enablePullDownRefresh: true,
navigationStyle: 'custom',
backgroundColor:'#F8F8F8'
},
})
@@ -66,7 +67,7 @@ const bloggerInfo = computed(() => {
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const calcIsShowQuickNavigationEnabled = computed(() => !!haloConfigs.value.pageConfig?.homeConfig?.useQuickNavigation)
const calcIsShowQuickNavigationEnabled = computed(() => haloConfigs.value.pageConfig?.homeConfig?.useQuickNavigation)
const calcIsShowCategory = computed(() => {
if (calcAuditModeEnabled.value)
@@ -241,7 +242,7 @@ async function handleGetArticleList() {
loadMoreText.value = t('common.loading')
try {
const res = await getPostList({ ...queryParams.value })
const res = await getPostList({ ...toRaw(queryParams.value) })
result.value.hasNext = res.data.hasNext
articleList.value = isLoadMore.value
? articleList.value.concat(res.data.items)
@@ -277,11 +278,7 @@ function handleToArticleDetail(article: IPost) {
function handleToCategoryPage() {
uni.switchTab({ url: '/pages/tabbar/category/category' })
}
function handleToArticlesPage() {
uni.navigateTo({ url: '/pages-blog/articles/articles' })
}
function handleToCategoryBy(category: ICategory) {
if (calcAuditModeEnabled.value)
return
@@ -291,7 +288,7 @@ function handleToCategoryBy(category: ICategory) {
}
function handleToSearch() {
uni.navigateTo({ url: '/pages-blog/articles/articles' })
uni.navigateTo({ url: '/pages-blog/search/search' })
}
function handleOnLogoToPage() {
@@ -374,7 +371,7 @@ handleQuery()
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col">
<view class="min-h-screen w-screen flex flex-col">
<!-- 顶部栏 -->
<view class="header flex items-center gap-4 px-3 py-1.5">
<image class="logo h-[60rpx] w-[60rpx] rounded-3xl" :src="appInfo.logo" mode="scaleToFill" @click="handleOnLogoToPage" />
@@ -398,7 +395,7 @@ handleQuery()
<block v-else>
<!-- 轮播 Banner -->
<view v-if="bannerConfig?.enabled" class="bg-white pb-6">
<view v-if="bannerConfig?.enabled" class="bg-white mb-4">
<view v-if="bannerList.length !== 0" class="banner mx-3 mt-3 overflow-hidden rounded-xl">
<uh-swiper
:height="bannerConfig.height"
@@ -414,11 +411,11 @@ handleQuery()
</view>
<!-- 快捷导航 -->
<view v-if="calcIsShowQuickNavigationEnabled && navList.filter(x => x.show).length" class="nav-box mx-6 mb-6 mt-4 overflow-hidden rounded-xl bg-white p-3">
<view v-if="navList.filter(x => x.show).length" class="nav-box px-4 overflow-hidden rounded-xl bg-white p-3">
<view class="page-item-title font-bold">
快捷导航
</view>
<view class="nav-list grid grid-cols-4 mt-6 gap-6">
<view class="nav-list grid grid-cols-5 mt-6 gap-6">
<template v-for="item in navList.filter(x => x.show)" :key="item.key">
<view class="nav-item flex flex-col items-center gap-3" @click="handleClickNav(item)">
<view class="nav-item-icon h-[88rpx] w-[88rpx] flex items-center justify-center rounded-3xl" :style="{ backgroundColor: item.bgColor }">
@@ -463,7 +460,7 @@ handleQuery()
<view class="page-item-title font-bold">
最新列表
</view>
<view class="show-more flex items-center justify-center rounded-xl bg-white" @click="handleToArticlesPage">
<view class="show-more flex items-center justify-center rounded-xl bg-white" @click="handleToSearch">
<wd-icon name="arrow-right" size="12px" color="#909399" />
</view>
</view>
@@ -500,61 +497,4 @@ handleQuery()
/>
</view>
</template>
<style scoped lang="scss">
.app-page {
display: flex;
flex-direction: column;
}
.header {
.logo {
flex-shrink: 0;
}
.search-input {
.search-text {
margin-left: 12rpx;
}
}
}
.page-item-title {
position: relative;
padding-left: 24rpx;
font-size: 32rpx;
color: #303133;
&::before {
content: '';
position: absolute;
left: 0;
top: 8rpx;
width: 8rpx;
height: 30rpx;
background-color: rgb(33 150 243);
border-radius: 6rpx;
}
}
.show-more {
width: 42rpx;
height: 42rpx;
box-shadow: 0 0 24rpx rgb(0 0 0 / 3%);
}
.to-top-btn {
position: fixed;
right: 24rpx;
bottom: 120rpx;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgb(0 0 0 / 10%);
z-index: 6;
}
</style>
+3 -21
View File
@@ -5,7 +5,6 @@
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getMomentList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
@@ -95,7 +94,6 @@ async function handleGetData() {
try {
const res = await getMomentList({ ...queryParams.value })
console.log('获取瞬间数据成功', res)
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext
@@ -250,7 +248,7 @@ onReachBottom(() => {
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col py-6">
<view class=" box-border min-h-screen w-screen flex flex-col py-6">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
@@ -262,14 +260,14 @@ onReachBottom(() => {
<wd-skeleton :row="3" :animated="true" />
</view>
<view v-else class="app-page-content">
<view v-else class="flex flex-col gap-y-2 p-4">
<view v-if="dataList.length === 0" class="min-h-[70vh] w-full flex items-center justify-center content-empty">
<wd-empty :description="t('common.empty')" />
</view>
<block v-else>
<!-- 瞬间卡片 -->
<view v-for="moment in dataList" :key="moment.metadata.name" class="moment-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white shadow-sm">
<view v-for="moment in dataList" :key="moment.metadata.name" class="flex flex-col overflow-hidden rounded-xl bg-white shadow-sm">
<view class="head flex items-center p-3 pb-0">
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 rounded-full" :src="moment.spec.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="nickname ml-3">
@@ -361,19 +359,3 @@ onReachBottom(() => {
</template>
</view>
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.moment-card {
.head {
.nickname {
.nickname-text {
/* 无额外样式 */
}
}
}
}
</style>