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:
+3
-1
@@ -48,4 +48,6 @@ src/pages.json
|
||||
# npx @dcloudio/uvm@latest
|
||||
|
||||
|
||||
.docs/
|
||||
.docs/
|
||||
.screenshots/
|
||||
.design/
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
// 必须设置name,instanceof、日志打印才正常
|
||||
this.name = this.constructor.name;
|
||||
this.code = errData.code;
|
||||
this.data = errData.data;
|
||||
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, UniHaloError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
export type CustomRequestOptions = UniApp.RequestOptions & {
|
||||
query?: Record<string, any>
|
||||
params?: Record<string, any>
|
||||
/** 出错时是否隐藏错误提示 */
|
||||
hideErrorToast?: boolean
|
||||
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user