Commit 61a43a4a by lijiabin

【需求 17679】 feat: 加入富文本框,回复弹窗等内容

parent 22f0e398
......@@ -25,6 +25,8 @@
"@element-plus/icons-vue": "^2.3.2",
"@vueuse/components": "^14.0.0",
"@vueuse/core": "^14.0.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"@wecom/jssdk": "^2.3.3",
"archiver": "^7.0.1",
"axios": "^1.13.0",
......
......@@ -312,3 +312,44 @@ export const addComplaint = (data: { articleId: number; reason: string }) => {
data,
})
}
/**
* 问吧-获取回答列表的评论(二级评论)
*/
export const getSecondCommentList = (data: { pId: number }) => {
return service.request<boolean>({
url: `/api/cultureComment/getQuestionComment`,
method: 'POST',
data,
})
}
/**
* 问吧 获取二级评论的子评论
*/
export const getSecondCommentChildren = (data: {
pid: number
current: number
size: number
articleId: number
}) => {
return service.request<BackendServicePageResult<CommentItemDto>>({
url: `/api/cultureComment/comment/children`,
method: 'POST',
data,
})
}
/**
* 根据commentid 获取这个评论的详情
*/
export const getCommentDetail = (id: number) => {
return service.request<CommentItemDto>({
url: `/api/cultureComment/questionCommentData`,
method: 'POST',
data: {
id,
},
})
}
......@@ -143,11 +143,13 @@
class="cursor-pointer hover:text-blue-500 transition-colors"
@click="handleReply(item)"
>
回复
回复111111111
</button>
</div>
</div>
<!-- 回复列表 -->
<!-- 问吧 和 其他 做区分 -->
<template v-if="!isQuestion">
<div v-if="item.children?.length" class="mt-3 ml-4 space-y-3">
<!-- 回复评论的内容 里面可能是 展示全部的 也有可能是展示5条之内容 -->
<div
......@@ -200,6 +202,18 @@
</div>
</div>
</div>
</template>
<!-- 问吧 如果有 子评论 直接变成 查看更多 -->
<template v-else>
<div v-if="item.childNum">
<button
class="cursor-pointer text-sm text-gray-500 mt-2"
@click="handleOpenCommentDialog(item)"
>
查看全部{{ item.childNum }} 条回复>
</button>
</div>
</template>
<!-- 只有大于5 才会显示这个 -->
<div class="ml-4" v-show="item.childrenNum > 5">
<!-- 展示 展开回复 -->
......@@ -293,27 +307,38 @@
</div>
</div>
</div>
<CommentDialog ref="commentDialogRef" :articleId="id" :pid="currentDialogCommentPid" />
</div>
</template>
<script lang="ts" setup>
import { getCommentList, addOrCancelCommentLike, addComment, getCommentChildren } from '@/api'
import {
getCommentList,
addOrCancelCommentLike,
addComment,
getCommentChildren,
getSecondCommentList,
} from '@/api'
import { usePageSearch, useScrollTop, useHintAnimation } from '@/hooks'
import { BooleanFlag } from '@/constants'
import type { CommentItemDto } from '@/api'
import dayjs from 'dayjs'
import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import CommentDialog from '../CommentDialog/index.vue'
const {
id,
defaultSize = 10,
isReal,
immediate = true,
isQuestion = false,
commentId = 0,
} = defineProps<{
id: number | string
id: number
defaultSize?: number
isReal: BooleanFlag
isQuestion?: boolean // 如果是问题的话 展示有点不一样
immediate?: boolean
commentId?: number // 如果是问题的话 需要传入评论id
}>()
const emit = defineEmits<{
......@@ -326,8 +351,9 @@ const total = defineModel<number>('total', { required: true, default: 0 })
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const userAvatar = computed(() => (isReal ? userInfo.value.avatar : userInfo.value.hiddenAvatar))
console.log(userAvatar)
const commentRef = useTemplateRef<HTMLElement | null>('commentRef')
const commentDialogRef = useTemplateRef<HTMLElement | null>('commentDialogRef')
const commentInputRef = useTemplateRef<HTMLElement | null>('commentInputRef')
const commentItemRefList = ref<HTMLElement[]>([])
// 回滚到评论框
......@@ -340,11 +366,15 @@ const { triggerAnimation } = useHintAnimation(commentInputRef, {
})
const { list, searchParams, goToPage, loading, changePageSize, refresh, search } = usePageSearch(
getCommentList,
isQuestion ? getSecondCommentList : getCommentList,
{
defaultParams: {
...(commentId
? { pid: commentId, sortType: 2 }
: {
articleId: id,
sortType: 2,
}),
},
defaultSize,
formatList(list: CommentItemDto[]) {
......@@ -422,6 +452,7 @@ const handleMyComment = async () => {
await addComment({
articleId: id,
content: myComment.value,
...(commentId ? { pid: commentId } : {}),
})
ElMessage.success('发表评论成功')
refresh()
......@@ -497,6 +528,12 @@ const handleUserInfo = (item: CommentItemDto) => {
router.push(`/otherUserPage/${item.userId}/${isReal}`)
}
const currentDialogCommentPid = ref(0)
const handleOpenCommentDialog = (item: CommentItemDto) => {
currentDialogCommentPid.value = item.id
commentDialogRef.value?.open()
}
defineExpose({
scrollToCommentBox: () => handleBackTop(),
search: () => search(),
......
<template>
<div style="border: 1px solid #ccc" class="h-full">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
style="height: 500px; overflow-y: hidden"
v-model="valueHtml"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
/>
</div>
</template>
<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { onBeforeUnmount, ref, shallowRef, onMounted } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { uploadFile } from '@/api'
const mode = 'default'
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
// 内容 HTML
const valueHtml = defineModel<string>()
// 模拟 ajax 异步获取内容
onMounted(() => {
setTimeout(() => {
valueHtml.value = '<p>模拟 Ajax 异步设置内容</p>'
}, 1500)
})
const toolbarConfig = {}
const editorConfig = { placeholder: '请输入内容...', MENU_CONF: {} }
// 修改 uploadImage 菜单配置
editorConfig.MENU_CONF['uploadImage'] = {
customUpload: async (file, insertFn) => {
const { data } = await uploadFile(file)
console.log(data)
insertFn(data.data[0].filePath)
},
}
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor // 记录 editor 实例,重要!
}
</script>
......@@ -6,6 +6,7 @@ export enum ArticleTypeEnum {
COLUMN = 'column', // 专栏
PRACTICE = 'practice', // 实践
INTERVIEW = 'interview', // 专访
LONG_ARTICLE = 'longArticle', // 长文章
}
// 发布状态枚举
......
......@@ -87,6 +87,7 @@
>专访</el-dropdown-item
>
</el-dropdown-menu>
<el-dropdown-item :command="ArticleTypeEnum.LONG_ARTICLE">长文章</el-dropdown-item>
</template>
</el-dropdown>
</div>
......@@ -154,6 +155,8 @@ const handlePost = async (type: ArticleTypeEnum) => {
router.push('/publishVideo')
} else if (type === ArticleTypeEnum.QUESTION) {
router.push(`/homePage/askTab#tabsRef?t=${Date.now()}`)
} else if (type === ArticleTypeEnum.LONG_ARTICLE) {
router.push('/publishLongArticle')
} else {
PublishDialogRef.value?.open(type)
}
......
......@@ -166,6 +166,11 @@ export const constantsRoute = [
name: 'CultureQuestionDetail',
component: () => import('@/views/questionDetail/index.vue'),
},
{
path: 'publishLongArticle',
name: 'CulturePublishLongArticle',
component: () => import('@/views/publishLongArticle/index.vue'),
},
// 发布文章
// {
// {
......
......@@ -26,7 +26,7 @@
<!-- 问题标题 -->
<h2
class="text-xl line-clamp-1 font-semibold text-gray-900 mb-2 leading-relaxed cursor-pointer hover:text-blue-600 transition-colors"
@click="router.push(`/questionDetail/${item.id}`)"
@click="openNewPage(`/questionDetail/${item.id}`)"
>
{{ item.title }}
</h2>
......@@ -158,6 +158,8 @@
:defaultSize="5"
:isReal="0"
:immediate="false"
:isQuestion="true"
:commentId="item.cultureCommentListVo?.id"
@commentSuccess="() => handleCommentSuccess(item)"
/>
</Transition>
......@@ -216,11 +218,15 @@ import { TABS_REF_KEY } from '@/constants'
import PublishBox from '@/components/common/PublishBox/index.vue'
import { ArticleTypeEnum } from '@/constants'
import dayjs from 'dayjs'
import { getArticleList, addOrCanceArticlelCollect, addOrCancelToAnswerList } from '@/api'
import {
getArticleList,
addOrCanceArticlelCollect,
addOrCancelToAnswerList,
getSecondCommentList,
} from '@/api'
import type { ArticleItemDto } from '@/api/article/types'
import { useQuestionStore } from '@/stores/question'
import ActionMore from '@/components/common/ActionMore/index.vue'
import router from '@/router'
const { fetchUserQestionNum } = useQuestionStore()
......@@ -307,6 +313,10 @@ const handleExpand = (item: ArticleItemDto) => {
item.isExpand = !item.isExpand
}
const openNewPage = (path: string) => {
window.open(path)
}
// 是否打开漫游
watch(
() => route.fullPath,
......@@ -325,6 +335,10 @@ onActivated(async () => {
}
refresh()
})
onMounted(() => {
console.log('父组件onmounted')
})
</script>
<style lang="scss" scoped>
.fade-enter-from,
......
<template>
<div class="min-h-screen bg-[#fff] p-6 font-sans">
<div class="max-w-7xl mx-auto">
<!-- 顶部面包屑或标题(可选) -->
<el-form :model="form" label-position="top" class="grid grid-cols-12 gap-6 items-start">
<!-- 左侧:沉浸式创作区 (占 9 列) -->
<div class="col-span-12 lg:col-span-9 space-y-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-8 min-h-[80vh]">
<!-- 标题输入:模拟大标题风格,去掉边框 -->
<el-form-item prop="title" class="mb-6 !border-b !border-gray-100 pb-2">
<el-input
v-model="form.title"
placeholder="请输入文章标题..."
class="title-input"
:maxlength="100"
show-word-limit
type="textarea"
:autosize="{ minRows: 1, maxRows: 2 }"
resize="none"
/>
</el-form-item>
<!-- 富文本编辑器 -->
<div class="editor-container">
<WangEditor v-model="form.content" style="height: 600px" />
</div>
</div>
</div>
<!-- 右侧:配置侧边栏 (占 3 列,吸顶) -->
<div class="col-span-12 lg:col-span-3 space-y-4 sticky top-4">
<!-- 卡片1:基础设置 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<div class="w-1 h-4 bg-blue-500 rounded-full"></div>
基础设置
</div>
<!-- 文章类型 -->
<el-form-item label="文章类型" prop="type">
<el-radio-group v-model="form.type" class="w-full grid grid-cols-3 gap-2">
<el-radio-button :value="ArticleTypeEnum.POST">帖子</el-radio-button>
<el-radio-button :value="ArticleTypeEnum.COLUMN">专栏</el-radio-button>
<el-radio-button :value="ArticleTypeEnum.INTERVIEW">专访</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 封面图 -->
<el-form-item label="封面图" prop="faceUrl">
<div class="w-full">
<UploadFile v-model="form.faceUrl" :limit="1" class="w-full" />
<div class="text-xs text-gray-400 mt-2">建议尺寸 16:9,支持 jpg/png</div>
</div>
</el-form-item>
</div>
<!-- 卡片2:高级配置 (专栏/专访特有) -->
<template
v-if="form.type === ArticleTypeEnum.COLUMN || form.type === ArticleTypeEnum.INTERVIEW"
>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<div class="w-1 h-4 bg-purple-500 rounded-full"></div>
专栏配置
</div>
<el-form-item label="所属栏目" prop="relateColumnId">
<el-select
v-model="form.relateColumnId"
placeholder="请选择专栏栏目"
class="w-full"
>
<el-option
v-for="item in columnList"
:key="item.id"
:value="item.id"
:label="item.title"
/>
</el-select>
</el-form-item>
<el-form-item label="主标签" prop="mainTagId">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item>
<el-form-item label="推荐设置">
<div class="flex items-center justify-between w-full">
<span class="text-gray-600 text-sm">是否推荐</span>
<el-switch
v-model="form.isRecommend"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
/>
</div>
</el-form-item>
<el-form-item v-if="form.type === ArticleTypeEnum.COLUMN">
<div class="flex items-center justify-between w-full">
<span class="text-gray-600 text-sm">同步同事吧</span>
<el-switch
v-model="form.isRelateColleague"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
/>
</div>
</el-form-item>
</div>
</template>
<!-- 卡片3:发布设置 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<div class="w-1 h-4 bg-orange-500 rounded-full"></div>
发布设置
</div>
<el-form-item prop="sendType" class="!mb-2">
<el-radio-group v-model="form.sendType" class="flex flex-col gap-3 w-full">
<div
class="flex items-center p-3 rounded-lg border cursor-pointer transition-all"
:class="
form.sendType === SendTypeEnum.IMMEDIATE
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-blue-300'
"
@click="form.sendType = SendTypeEnum.IMMEDIATE"
>
<el-radio :value="SendTypeEnum.IMMEDIATE" class="!mr-2">立即发布</el-radio>
<span class="text-xs text-gray-400 ml-auto">当前时间</span>
</div>
<div
class="flex flex-col p-3 rounded-lg border cursor-pointer transition-all"
:class="
form.sendType === SendTypeEnum.SCHEDULED
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-blue-300'
"
@click="form.sendType = SendTypeEnum.SCHEDULED"
>
<div class="flex items-center mb-2">
<el-radio :value="SendTypeEnum.SCHEDULED" class="!mr-2">定时发布</el-radio>
</div>
<el-date-picker
v-if="form.sendType === SendTypeEnum.SCHEDULED"
v-model="form.sendTime"
type="datetime"
placeholder="选择时间"
class="!w-full"
:disabled-date="
(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24
"
value-format="X"
size="small"
/>
</div>
</el-radio-group>
</el-form-item>
</div>
<div class="mb-6 flex items-center justify-end w-full">
<div class="flex gap-3">
<el-button type="primary" round class="!px-8 w-full!" @click="handlePublish">
发布
</el-button>
</div>
</div>
</div>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import WangEditor from '@/components/common/WangEditor/index.vue'
import { ArticleTypeEnum, SendTypeEnum, BooleanFlag, ReleaseStatusTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import { useResetData } from '@/hooks'
import { useColumnStore } from '@/stores/column'
import { storeToRefs } from 'pinia'
import { addOrUpdateArticle } from '@/api'
// ... (逻辑部分保持不变,直接复用您的即可)
const columnStore = useColumnStore()
const { columnList } = storeToRefs(columnStore)
const [form, resetForm] = useResetData({
articleType: ArticleTypeEnum.POST,
title: '',
content: '',
faceUrl: '',
relateColumnId: null, // 建议初始值设为null或undefined,配合placeholder
mainTagId: '',
tagList: [],
isRelateColleague: BooleanFlag.NO,
isRecommend: BooleanFlag.NO,
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
releaseStatus: ReleaseStatusTypeEnum.PUBLISH,
})
const filterTagsFn = (allTags: any[]) => {
return allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
}
const handlePublish = async () => {
const res = await addOrUpdateArticle(form.value)
console.log(res)
}
</script>
<style scoped lang="scss">
/* 覆盖 Element Plus 默认样式,使其更符合大标题风格 */
:deep(.title-input .el-textarea__inner) {
font-size: 24px;
font-weight: bold;
color: #333;
padding: 0;
border: none;
box-shadow: none;
background: transparent;
&::placeholder {
color: #a8abb2;
}
}
/* 隐藏 Radio Button 的圆点,改用卡片选择样式时需要 */
:deep(.el-radio-button__inner) {
border-radius: 8px !important;
border: 1px solid #dcdfe6;
border-left: 1px solid #dcdfe6 !important;
box-shadow: none !important;
padding: 8px 16px;
width: 100%;
}
:deep(.el-radio-button:first-child .el-radio-button__inner) {
border-left: 1px solid #dcdfe6;
}
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background-color: #ecf5ff;
border-color: #409eff;
color: #409eff;
box-shadow: none;
}
/* 让侧边栏标签文字稍微小一点 */
:deep(.el-form-item__label) {
font-weight: 500;
color: #4b5563;
}
</style>
<script setup lang="ts">
import { ref } from 'vue'
import { CaretTop, CaretBottom, ChatDotRound, Share, Star, Plus } from '@element-plus/icons-vue'
// --- 模拟数据 ---
const tags = ref([
{ id: 1, name: 'React' },
{ id: 2, name: 'Vue.js' },
])
const question = ref({
title: 'vue转react是什么感受?',
description:
'最近在面试,有一家公司各方面都很满意,但是因为他们是做阿里巴巴网店模板,只能用react写,所以要考虑学习react,来知乎问问各位大佬意见...',
viewCount: '12,304',
followCount: 52,
})
const answers = ref([
{
id: 1,
user: {
name: '我们',
avatar: 'https://picsum.photos/id/64/100/100',
bio: '各位都脑测了就回复了,你们的一切脑测都是对的',
},
votes: 2,
content: '什么时候这群人才能明白框架没那么重要,重要的是逻辑能力和代码水平',
publishDate: '2025-10-13 11:42',
commentCount: 0,
},
{
id: 2,
user: {
name: '老富甲',
avatar: 'https://picsum.photos/id/1025/100/100',
bio: '程序员',
},
votes: 12,
content: `初入前端的时候,写的是 vue 2,非常简单的就入门了,然后就是自己研究html和css,对调样式很感兴趣,乐在其中。<br><br>换公司学 react,由于连 ts 都不会,同时学 react 官网和 ts 官网,两个官网都撸了4-5遍,差不多3天吧,就差不多能写基础的页面了,后续就是熟练度问题了,然后就是要学习各种组件库,其实还好,不会很难的,并且会了之后感觉很爽,比写vue爽了非常非常多。<br><br>写 react 我感觉就是比写 vue 更有激情,可能因为 vscode 对 ts、react 的插件体验更好点...`,
publishDate: '2025-10-12 18:20',
commentCount: 2,
},
])
const isFollowing = ref(false)
</script>
<template>
<div class="min-h-screen p-6 font-sans text-gray-800 flex justify-center">
<!--
......@@ -70,12 +22,12 @@ const isFollowing = ref(false)
<!-- 标题 -->
<h1 class="text-2xl font-bold text-gray-900 mb-3 leading-snug">
{{ question.title }}
{{ questionDetail.title }}
</h1>
<!-- 描述 -->
<p class="text-gray-600 leading-relaxed mb-6 text-sm md:text-base">
{{ question.description }}
{{ questionDetail.description }}
<span class="text-blue-600 cursor-pointer hover:underline text-sm font-medium ml-1"
>显示全部</span
>
......@@ -213,11 +165,23 @@ const isFollowing = ref(false)
</div>
</div>
</template>
<script setup lang="ts">
import { getArticleDetail } from '@/api/article'
import type { ArticleItemDto } from '@/api/article/types'
<style scoped>
/*
样式微调:
1. 卡片边框改为 border-gray-100,这是一种极淡的边框,配合阴影,质感更细腻。
2. 字体大小微调:正文使用 text-sm md:text-base,让信息密度稍微高一点,更像知乎PC端的阅读体验。
*/
</style>
const route = useRoute()
const questionId = route.params.id as string
const questionDetail = ref<ArticleItemDto>({} as ArticleItemDto)
const getQuestionDetail = async () => {
const res = await getArticleDetail(questionId)
console.log(res)
}
getQuestionDetail()
onMounted(() => {
getQuestionDetail()
})
</script>
<style scoped></style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment