Commit bed64a7f by lijiabin

【需求 20331】 feat: 关于评论相关的加入表情和图片

parent c137a089
...@@ -326,6 +326,7 @@ export interface AddCommentDto { ...@@ -326,6 +326,7 @@ export interface AddCommentDto {
articleId: number | string articleId: number | string
content: string content: string
pid?: number | string pid?: number | string
imgUrl?: string
} }
/** /**
...@@ -359,6 +360,7 @@ export interface CommentItemDto { ...@@ -359,6 +360,7 @@ export interface CommentItemDto {
showComment: boolean showComment: boolean
isExpand: boolean isExpand: boolean
childNum: number childNum: number
imgUrl: string
} }
/** /**
......
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1653463274928" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="23443" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M514.286299 2.505864h3.565417V895.785974h-3.565417z" fill="#EDA639" p-id="23444"></path><path d="M69.430967 447.365225h893.276081v3.55736H69.430967z" fill="#EDA639" p-id="23445"></path><path d="M517.811429 452.578389m-430.932074 0a430.932074 430.932074 0 1 0 861.864148 0 430.932074 430.932074 0 1 0-861.864148 0Z" fill="#FFE770" p-id="23446"></path><path d="M520.027225 493.522274v160.145663" fill="#FFE770" p-id="23447"></path><path d="M520.027225 675.024184a21.352218 21.352218 0 0 1-21.352218-21.356247v-160.145663a21.352218 21.352218 0 0 1 42.708465 0v160.145663a21.352218 21.352218 0 0 1-21.356247 21.356247z" fill="#6E6E96" p-id="23448"></path><path d="M300.917211 313.051716l62.279988-62.279988 62.279988 62.279988" fill="#FFE770" p-id="23449"></path><path d="M425.477187 334.403934a21.299845 21.299845 0 0 1-15.099644-6.252574L363.197199 280.971016 316.016855 328.15136a21.352218 21.352218 0 1 1-30.199288-30.199288l62.279988-62.279988a21.352218 21.352218 0 0 1 30.199288 0l62.279988 62.279988a21.352218 21.352218 0 0 1-15.099644 36.451862z" fill="#6E6E96" p-id="23450"></path><path d="M610.536457 313.051716l62.284017-62.279988 62.279988 62.279988" fill="#FFE770" p-id="23451"></path><path d="M735.100462 334.403934a21.299845 21.299845 0 0 1-15.099644-6.252574l-47.180344-47.180344-47.184373 47.180344a21.356247 21.356247 0 0 1-30.195259-30.199288l62.279988-62.279988a21.352218 21.352218 0 0 1 30.195259 0l62.279989 62.279988a21.352218 21.352218 0 0 1-15.095616 36.451862z" fill="#6E6E96" p-id="23452"></path><path d="M519.624353 45.947556c-3.831313 0-7.65054 0.068488-11.449623 0.173235 203.095851 5.785242 365.960901 172.231825 365.960901 376.72967 0 208.159953-168.746981 376.898877-376.906934 376.898877-208.155924 0-376.902905-168.738924-376.902906-376.898877a377.615989 377.615989 0 0 1 12.061989-94.924709 406.368966 406.368966 0 0 0-19.523179 124.777527c0 224.645477 182.114276 406.755723 406.759752 406.755724s406.755723-182.110247 406.755724-406.755724c0-224.645477-182.110247-406.755723-406.755724-406.755723z" fill="#FF9900" opacity=".24" p-id="23453"></path><path d="M517.811429 901.305321c-103.779837 0-204.98935-36.262512-284.979593-102.095831-78.906517-64.947001-133.733373-155.508606-154.380564-255.005914a451.740415 451.740415 0 0 1-9.366775-91.629216C69.084497 205.14647 270.383539 3.847428 517.811429 3.847428c247.42789 0 448.726932 201.299042 448.726932 448.726932 0 247.42789-201.299042 448.730961-448.726932 448.730961z m0-861.864149c-227.803993 0-413.137216 185.333223-413.137216 413.137217 0 28.43068 2.900679 56.821072 8.621461 84.397663 19.003474 91.576843 69.487369 174.951211 142.153398 234.757565 73.636951 60.604041 166.813196 93.981989 262.366386 93.981988 227.808022 0 413.137216-185.333223 413.137216-413.137216S745.619451 39.441172 517.811429 39.441172z" fill="#6E6E96" p-id="23454"></path><path d="M809.635818 752.069432m-135.542268 0a135.542268 135.542268 0 1 0 271.084536 0 135.542268 135.542268 0 1 0-271.084536 0Z" fill="#FFE770" p-id="23455"></path><path d="M809.635818 905.406558c-84.546726 0-153.337126-68.786372-153.337126-153.341154 0-84.546726 68.786372-153.337126 153.337126-153.337126s153.341155 68.786372 153.341155 153.337126c-0.004029 84.554783-68.7904 153.341155-153.341155 153.341154z m0-271.084536c-64.926857 0-117.74741 52.816524-117.74741 117.74741s52.816524 117.751439 117.74741 117.751439 117.751439-52.816524 117.751439-117.751439c-0.004029-64.930886-52.820553-117.74741-117.751439-117.74741z" fill="#6E6E96" p-id="23456"></path><path d="M218.864263 752.069432m-135.542268 0a135.542268 135.542268 0 1 0 271.084536 0 135.542268 135.542268 0 1 0-271.084536 0Z" fill="#FFE770" p-id="23457"></path><path d="M218.864263 905.406558c-84.550754 0-153.337126-68.786372-153.337126-153.341154 0-84.546726 68.786372-153.337126 153.337126-153.337126s153.337126 68.786372 153.337126 153.337126c0 84.554783-68.786372 153.341155-153.337126 153.341154z m0-271.084536c-64.926857 0-117.74741 52.816524-117.74741 117.74741s52.820553 117.751439 117.74741 117.751439 117.751439-52.816524 117.751439-117.751439c-0.004029-64.930886-52.824581-117.74741-117.751439-117.74741z" fill="#6E6E96" p-id="23458"></path><path d="M305.248085 455.213172a61.824743 28.845638 0 1 0 123.649486 0 61.824743 28.845638 0 1 0-123.649486 0Z" fill="#FF0000" opacity=".18" p-id="23459"></path><path d="M615.403151 455.213172a61.820714 28.845638 0 1 0 123.641429 0 61.820714 28.845638 0 1 0-123.641429 0Z" fill="#FF0000" opacity=".18" p-id="23460"></path><path d="M410.079418 993.43007h193.378577a16.114881 16.114881 0 0 0 0-32.229763h-193.378577a16.114881 16.114881 0 0 0 0 32.229763z" fill="#6E6E96" opacity=".29" p-id="23461"></path><path d="M675.435114 993.43007h56.402085a16.114881 16.114881 0 0 0 0-32.229763h-56.402085a16.114881 16.114881 0 0 0 0 32.229763z" fill="#6E6E96" opacity=".17" p-id="23462"></path><path d="M332.993882 961.200307h-44.315924a16.114881 16.114881 0 0 0 0 32.229763h44.315924a16.114881 16.114881 0 0 0 0-32.229763z" fill="#6E6E96" opacity=".17" p-id="23463"></path></svg>
\ No newline at end of file
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770017871834" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1986" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M515.9424 512.768m-450.816 0a450.816 450.816 0 1 0 901.632 0 450.816 450.816 0 1 0-901.632 0Z" fill="#FFF1DC" p-id="1987"></path><path d="M516.4544 251.1872c69.4784 0 138.9568-0.1024 208.4352 0.0512 50.4832 0.1024 74.5472 23.9616 74.5472 73.8816 0.0512 124.9792 0.0512 250.0096 0 374.9888 0 49.8176-24.2176 74.0864-74.5984 74.1888-138.9568 0.1536-277.9136 0.1536-416.8192 0-52.0192-0.0512-75.4176-23.4496-75.4688-75.1616-0.1536-124.3648-0.1536-248.7296 0-373.0944 0.0512-51.3536 23.7568-74.7008 75.52-74.8032 69.4272-0.1536 138.9056-0.0512 208.384-0.0512z m-110.5408 281.5488c-36.864 48.896-72.2944 95.8976-109.6704 145.4592h439.6544c-48.0256-65.2288-94.0032-127.7952-141.2096-191.8976-37.632 48.1792-73.472 94.0544-110.0288 140.9024-26.9824-32.3584-51.968-62.4128-78.7456-94.464zM371.5072 439.296c32.768 0.1536 57.7024-24.4736 57.5488-56.9344-0.1536-30.976-25.4976-56.7808-56.1664-57.1904-31.3856-0.4608-58.4192 25.6512-58.7264 56.7296-0.3584 32.2048 24.6272 57.1904 57.344 57.3952z" fill="#FC7032" p-id="1988"></path><path d="M516.4544 251.1872c-69.4784 0-138.9568-0.1024-208.4352 0.0512-51.7632 0.1024-75.4688 23.4496-75.52 74.8032-0.1536 124.3648-0.1536 248.7296 0 373.0944 0.0512 51.712 23.4496 75.1104 75.4688 75.1616 85.0432 0.1024 170.1376 0 255.1808 0a452.77184 452.77184 0 0 0 98.7136-96.0512H296.192c37.376-49.5616 72.8064-96.5632 109.6704-145.4592 26.7264 32.0512 51.7632 62.1056 78.7456 94.464 36.608-46.848 72.448-92.7232 110.0288-140.9024 34.2016 46.4384 67.7376 92.1088 101.888 138.496 35.7888-64.6144 56.1664-138.9568 56.1664-218.0096 0-54.6816-9.728-107.0592-27.5456-155.5456h-0.3584c-69.376-0.2048-138.8544-0.1024-208.3328-0.1024zM371.5072 439.296c-32.7168-0.1536-57.7024-25.1904-57.3952-57.3952 0.3072-31.0784 27.3408-57.1904 58.7264-56.7296 30.6688 0.4608 56.0128 26.2144 56.1664 57.1904 0.2048 32.4096-24.7296 57.088-57.4976 56.9344z" fill="#FF7E3E" p-id="1989"></path><path d="M309.4528 678.1952h-13.1584c37.376-49.5616 72.8064-96.5632 109.6704-145.4592 18.6368 22.3744 36.5056 43.776 54.7328 65.6384 101.8368-81.7664 167.3728-206.7968 168.7552-347.2384-37.632 0-75.3152 0.0512-112.9472 0.0512-69.4784 0-138.9568-0.1024-208.4352 0.0512-51.7632 0.1024-75.4688 23.4496-75.52 74.8032-0.1536 122.7776-0.1024 245.504 0 368.2816a448.6656 448.6656 0 0 0 76.9024-16.128z m63.4368-353.024c30.6688 0.4608 56.0128 26.2144 56.1664 57.1904 0.1536 32.4096-24.7808 57.088-57.5488 56.9344-32.7168-0.1536-57.7024-25.1904-57.3952-57.3952 0.3072-31.0784 27.392-57.1904 58.7776-56.7296z" fill="#FF9552" p-id="1990"></path><path d="M351.232 435.6608c-22.1696-7.9872-37.376-28.672-37.12-53.8112 0.3072-31.0784 27.3408-57.1904 58.7264-56.7296 22.784 0.3072 42.5984 14.6432 51.3536 34.7136a451.1744 451.1744 0 0 0 61.0816-108.6976c-59.0848 0-118.1696-0.0512-177.2544 0.0512-51.7632 0.1024-75.4688 23.4496-75.52 74.8032-0.0512 59.9552 0 119.9104 0 179.8656A448.06656 448.06656 0 0 0 351.232 435.6608z" fill="#FFA56A" p-id="1991"></path></svg>
\ No newline at end of file
<template>
<div class="flex-1">
<div>
<!-- <el-input
v-model="commentStr"
type="textarea"
:placeholder="placeholder"
:rows="3"
></el-input> -->
<div
style="border: 1px solid rgb(229, 231, 235)"
class="relative w-full rounded-lg border border-gray-200 px-3 py-2 transition focus-within:border-[var(--el-color-primary)] focus-within:ring-1 focus-within:ring-[var(--el-color-primary)] bg-white"
>
<!-- 文本输入区 -->
<textarea
ref="commentInputRef"
v-model="commentStr"
class="w-full resize-none border-none outline-none text-sm leading-5 text-gray-800 min-h-14"
:placeholder="placeholder"
/>
<!-- 定位到右边 -->
<!-- <span class="flex justify-end text-xs text-gray-400">
{{ form.content.length }} / 500
</span> -->
<div class="flex justify-between items-center mt-2">
<div
v-if="imgList.length"
class="flex flex-wrap gap-2"
v-loading="uploadPercent > 0"
:element-loading-text="uploadPercent + '%'"
>
<div
class="relative w-20 h-20 rounded-lg overflow-hidden group"
v-for="img in imgList"
:key="img"
>
<!-- 删除按钮 -->
<div
class="absolute top-1 right-1 z-10 w-5 h-5 flex items-center justify-center bg-black/60 rounded-full cursor-pointer opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-black/80 hover:scale-110"
@click="handleDeleteImg(img)"
>
<el-icon class="text-white text-xs">
<IEpClose />
</el-icon>
</div>
<el-image
:src="img"
class="w-full h-full rounded-lg border border-gray-200"
fit="cover"
/>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-between items-center mt-3">
<div>
<!-- 操作栏 添加图片 表情包等 -->
<div class="flex items-center gap-2">
<div
class="w-8 h-8 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg flex items-center justify-center"
>
<input
accept="image/*"
@change="handleFileChange"
type="file"
class="hidden"
ref="fileInputRef"
/>
<el-icon size="30" class="cursor-pointer" @click="fileInputRef?.click()">
<svg-icon name="icon_picture" />
</el-icon>
</div>
<div
class="w-8 h-8 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg flex items-center justify-center"
>
<el-popover placement="bottom" trigger="click" width="384">
<template #reference>
<el-icon size="20" @mousedown.prevent>
<svg-icon name="icon_face" class="cursor-pointer"
/></el-icon>
</template>
<!-- 表情面板 -->
<el-scrollbar class="h-50">
<div class="flex flex-wrap">
<span
v-for="item in emojis"
:key="item.name"
class="cursor-pointer hover:bg-gray-100 rounded p-1 flex items-center justify-center"
@click="selectEmoji(item)"
>
<img :src="item.url" alt="" class="w-6 h-6" />
</span>
</div>
</el-scrollbar>
</el-popover>
</div>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-gray-500">
<button class="hover:text-blue-500 transition-colors">
<i class="i-carbon-face-satisfied"></i>
</button>
<button class="hover:text-blue-500 transition-colors">
<i class="i-carbon-image"></i>
</button>
</div>
<button
class="cursor-pointer disabled:opacity-50 px-6 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-full text-sm hover:shadow-lg transition-all"
:disabled="!displayComment || loading"
@click="emit('submit')"
>
<div v-show="loading" class="flex items-center gap-2">
<el-icon><IEpLoading /></el-icon>
<span>发表中...</span>
</div>
<div v-show="!loading">发表</div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import emojis from '@/utils/emoji/face.json'
import { useUploadImg } from '@/hooks'
import type { IEmoji } from '@/utils/emoji/type'
const commentStr = defineModel<string>('modelValue', {
required: true,
})
const commentImgStr = defineModel<string>('commentImgStr', {
required: true,
})
const { placeholder = '写下你的评论...', loading } = defineProps<{
placeholder?: string
loading: boolean
}>()
const emit = defineEmits<{
(e: 'submit'): void
}>()
const displayComment = computed(() => commentStr.value?.trim())
const fileInputRef = useTemplateRef('fileInputRef')
const commentInputRef = useTemplateRef<HTMLTextAreaElement>('commentInputRef')
const { handleFileChange, handleDeleteImg, uploadPercent, imgList } = useUploadImg(commentImgStr)
const selectEmoji = async (item: IEmoji) => {
const textarea = commentInputRef.value
if (!textarea) return
// 当选中一段文本时 这俩值是不一样
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = commentStr.value
// 插入内容(你可以是 [微笑],也可以是 😀)
commentStr.value = value.slice(0, start) + item.name + value.slice(end)
// 插入后把光标放到表情后面
await nextTick()
textarea.focus()
textarea.selectionStart = textarea.selectionEnd = start + item.name.length
}
defineExpose({
focus: async () => {
await nextTick()
commentInputRef.value?.focus()
},
})
</script>
...@@ -11,21 +11,101 @@ ...@@ -11,21 +11,101 @@
<el-avatar :size="40" :src="userInfo.hiddenAvatar" /> <el-avatar :size="40" :src="userInfo.hiddenAvatar" />
<!-- 评论输入框 --> <!-- 评论输入框 -->
<el-input <div
v-model="commentContent" style="border: 1px solid rgb(229, 231, 235)"
type="textarea" class="relative flex-1 rounded-lg border border-gray-200 px-3 py-2 transition focus-within:border-[var(--el-color-primary)] focus-within:ring-1 focus-within:ring-[var(--el-color-primary)] bg-white"
:rows="4" >
placeholder="写下你的评论..." <!-- 文本输入区 -->
maxlength="500" <textarea
show-word-limit ref="commentInputRef"
class="flex-1" v-model="commentStr"
/> class="w-full resize-none border-none outline-none text-sm leading-5 text-gray-800 min-h-30"
placeholder="写下你的评论..."
/>
<!-- 定位到右边 -->
<!-- <span class="flex justify-end text-xs text-gray-400">
{{ form.content.length }} / 500
</span> -->
<!-- 底部工具栏 -->
<div class="flex justify-between items-center mt-2">
<div
v-if="imgList.length"
class="flex flex-wrap gap-2"
v-loading="uploadPercent > 0"
:element-loading-text="uploadPercent + '%'"
>
<div
class="relative w-20 h-20 rounded-lg overflow-hidden group"
v-for="img in imgList"
:key="img"
>
<!-- 删除按钮 -->
<div
class="absolute top-1 right-1 z-10 w-5 h-5 flex items-center justify-center bg-black/60 rounded-full cursor-pointer opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-black/80 hover:scale-110"
@click="handleDeleteImg(img)"
>
<el-icon class="text-white text-xs">
<IEpClose />
</el-icon>
</div>
<el-image
:src="img"
class="w-full h-full rounded-lg border border-gray-200"
fit="cover"
/>
</div>
</div>
</div>
</div>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2 items-center">
<el-button @click="handleClose" class="rounded-lg">取消</el-button> <!-- 上传文件 和 表情 -->
<div
class="w-8 h-8 cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg flex items-center justify-center"
>
<input
accept="image/*"
@change="handleFileChange"
type="file"
class="hidden"
ref="fileInputRef"
/>
<el-icon size="30" @click="fileInputRef?.click()">
<svg-icon name="icon_picture" />
</el-icon>
</div>
<div
class="w-8 h-8 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg flex items-center justify-center"
>
<el-popover placement="bottom" trigger="click" width="384">
<template #reference>
<el-icon size="20" @mousedown.prevent>
<svg-icon name="icon_face" class="cursor-pointer"
/></el-icon>
</template>
<!-- 表情面板 -->
<el-scrollbar class="h-50">
<div class="flex flex-wrap">
<span
v-for="item in emojis"
:key="item.name"
class="cursor-pointer hover:bg-gray-100 rounded p-1 flex items-center justify-center"
@click="selectEmoji(item)"
>
<img :src="item.url" alt="" class="w-6 h-6" />
</span>
</div>
</el-scrollbar>
</el-popover>
</div>
<el-button <el-button
:disabled="isDisabled || loading"
:loading="loading"
type="primary" type="primary"
@click="handleSubmit" @click="handleSubmit"
class="px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200" class="px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200"
...@@ -40,48 +120,81 @@ ...@@ -40,48 +120,81 @@
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { addComment } from '@/api' import { addComment } from '@/api'
import { useUploadImg } from '@/hooks'
import emojis from '@/utils/emoji/face.json'
import type { IEmoji } from '@/utils/emoji/type'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'commentSuccess'): void (e: 'commentSuccess'): void
}>() }>()
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
// 弹窗显示状态 // 弹窗显示状态
const visible = ref(false) const visible = ref(false)
// 评论内容 // 评论内容
const commentContent = ref('') const commentStr = ref('')
const commentImgStr = ref('')
const loading = ref(false)
const isDisabled = computed(() => !commentStr.value.trim())
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const { handleFileChange, handleDeleteImg, uploadPercent, imgList } = useUploadImg(commentImgStr)
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const commentInputRef = useTemplateRef<HTMLTextAreaElement>('commentInputRef')
let articleId = 0 let articleId = 0
// 暴露 open 方法 // 暴露 open 方法
const open = (id: number) => { const open = (id: number) => {
articleId = id articleId = id
visible.value = true visible.value = true
commentContent.value = '' commentStr.value = ''
commentImgStr.value = ''
} }
// 关闭弹窗 // 关闭弹窗
const handleClose = () => { const handleClose = () => {
visible.value = false visible.value = false
commentContent.value = '' commentStr.value = ''
commentImgStr.value = ''
}
const selectEmoji = async (item: IEmoji) => {
const textarea = commentInputRef.value
if (!textarea) return
// 当选中一段文本时 这俩值是不一样
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = commentStr.value
// 插入内容(你可以是 [微笑],也可以是 😀)
commentStr.value = value.slice(0, start) + item.name + value.slice(end)
// 插入后把光标放到表情后面
await nextTick()
textarea.focus()
textarea.selectionStart = textarea.selectionEnd = start + item.name.length
} }
// 提交评论 // 提交评论
const handleSubmit = async () => { const handleSubmit = async () => {
if (!commentContent.value.trim()) { loading.value = true
ElMessage.warning('请输入评论内容')
return
}
// TODO: 这里处理提交逻辑 try {
await addComment({ await addComment({
articleId: articleId, articleId: articleId,
content: commentContent.value, content: commentStr.value,
}) imgUrl: commentImgStr.value,
console.log('评论内容:', commentContent.value) })
ElMessage.success('评论发表成功') console.log('评论内容:', commentStr.value)
handleClose() ElMessage.success('评论发表成功')
emit('commentSuccess') handleClose()
emit('commentSuccess')
} catch (error) {
console.log(error)
} finally {
loading.value = false
}
} }
// 暴露方法给父组件 // 暴露方法给父组件
......
...@@ -49,7 +49,28 @@ ...@@ -49,7 +49,28 @@
</div> </div>
</div> </div>
<p class="text-gray-800 text-base leading-relaxed mb-3">{{ parentComment.content }}</p> <p
class="text-gray-800 text-base leading-relaxed mb-3"
v-html="parseEmoji(parentComment.content)"
></p>
<!-- 下方图片 -->
<div class="flex flex-wrap gap-2" v-if="parentComment.imgUrl">
<div
v-for="(img, imgIndex) in parentComment.imgUrl.split(',').filter(Boolean)"
:key="imgIndex"
class="w-20 h-20 rounded-lg overflow-hidden mb-2 cursor-pointer"
>
<el-image
:src="img"
:preview-teleported="true"
class="w-full h-full object-cover"
:preview-src-list="parentComment.imgUrl.split(',').filter(Boolean)"
:initial-index="imgIndex"
fit="cover"
/>
</div>
</div>
<div class="text-sm text-gray-400 flex items-center gap-4"> <div class="text-sm text-gray-400 flex items-center gap-4">
<span>{{ dayjs(parentComment.createTime * 1000).format('MM-DD HH:mm') }}</span> <span>{{ dayjs(parentComment.createTime * 1000).format('MM-DD HH:mm') }}</span>
...@@ -102,9 +123,27 @@ ...@@ -102,9 +123,27 @@
</div> </div>
</div> </div>
<p class="text-gray-700 text-base mb-3 break-all leading-relaxed"> <p
{{ item.content }} class="text-gray-700 text-base mb-3 break-all leading-relaxed"
</p> v-html="parseEmoji(item.content)"
></p>
<!-- 下方图片 -->
<div class="flex flex-wrap gap-2" v-if="item.imgUrl">
<div
v-for="(img, imgIndex) in item.imgUrl.split(',').filter(Boolean)"
:key="imgIndex"
class="w-20 h-20 rounded-lg overflow-hidden mb-2 cursor-pointer"
>
<el-image
:src="img"
:preview-teleported="true"
class="w-full h-full object-cover"
:preview-src-list="item.imgUrl.split(',').filter(Boolean)"
:initial-index="imgIndex"
fit="cover"
/>
</div>
</div>
<div class="text-sm text-gray-400"> <div class="text-sm text-gray-400">
<span>{{ formatDate(item.createTime) }}</span> <span>{{ formatDate(item.createTime) }}</span>
...@@ -210,6 +249,7 @@ import { ...@@ -210,6 +249,7 @@ import {
import type { CommentItemDto } from '@/api' import type { CommentItemDto } from '@/api'
import { BooleanFlag } from '@/constants' import { BooleanFlag } from '@/constants'
import { usePageSearch } from '@/hooks' // 假设你有这个hook import { usePageSearch } from '@/hooks' // 假设你有这个hook
import { parseEmoji } from '@/utils/emoji'
// Props // Props
const { articleId, pid } = defineProps<{ const { articleId, pid } = defineProps<{
......
<template> <template>
<el-dialog v-model="dialogVisible" title="选择标签" width="500px" :close-on-click-modal="false"> <el-dialog
v-model="dialogVisible"
title="选择标签"
width="500px"
:close-on-click-modal="false"
top="30vh"
>
<div class="space-y-6 px-2"> <div class="space-y-6 px-2">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="text-sm text-gray-700 w-16 flex-shrink-0">主标签</div> <div class="text-sm text-gray-700 w-16 flex-shrink-0">主标签</div>
......
...@@ -28,18 +28,64 @@ ...@@ -28,18 +28,64 @@
</div> </div>
<!-- 主要内容输入 --> <!-- 主要内容输入 -->
<div class="relative mb-3"> <div class="relative mb-3">
<el-input <div
style="border: 1px solid rgb(229, 231, 235)"
class="relative w-full rounded-lg border border-gray-200 px-3 py-2 transition focus-within:border-[var(--el-color-primary)] focus-within:ring-1 focus-within:ring-[var(--el-color-primary)] bg-white"
>
<!-- 文本输入区 -->
<textarea
v-model="form.content"
:placeholder="textMap[type].content"
class="w-full resize-none border-none outline-none text-sm leading-5 text-gray-800 min-h-30"
:maxlength="maxLength"
/>
<!-- 定位到右边 -->
<span class="flex justify-end text-xs text-gray-400">
{{ form.content.length }} / 500
</span>
<!-- 底部工具栏 -->
<div class="flex justify-between items-center mt-2">
<!-- 图片列表 -->
<div
v-if="imgList.length"
class="flex flex-wrap gap-2"
v-loading="uploadPercent > 0"
:element-loading-text="uploadPercent + '%'"
>
<div
class="relative w-20 h-20 rounded-lg overflow-hidden group"
v-for="img in imgList"
:key="img"
>
<!-- 删除按钮 -->
<div
class="absolute top-1 right-1 z-10 w-5 h-5 flex items-center justify-center bg-black/60 rounded-full cursor-pointer opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-black/80 hover:scale-110"
@click="handleDeleteImg(img)"
>
<el-icon class="text-white text-xs">
<IEpClose />
</el-icon>
</div>
<el-image
:src="img"
class="w-full h-full rounded-lg border border-gray-200"
fit="cover"
/>
</div>
</div>
</div>
</div>
<!-- <el-input
type="textarea" type="textarea"
:placeholder="textMap[type].content" :placeholder="textMap[type].content"
:rows="6" :rows="6"
:maxlength="maxLength" :maxlength="maxLength"
show-word-limit show-word-limit
resize="none" resize="none"
class="main-textarea"
v-model="form.content" v-model="form.content"
/> /> -->
<!-- 字符计数 -->
<!-- <div class="absolute bottom-3 right-3 text-xs text-gray-400">1/30</div> -->
</div> </div>
<!-- 标签内容 --> <!-- 标签内容 -->
<div class="mb-2"> <div class="mb-2">
...@@ -56,13 +102,12 @@ ...@@ -56,13 +102,12 @@
</div> </div>
</div> </div>
<!-- 图片相关 --> <!-- 图片相关 -->
<div <!-- <div
v-if="form.imgUrl.length" v-if="form.imgUrl.length"
class="flex flex-wrap gap-2 w-fit" class="flex flex-wrap gap-2 w-fit"
v-loading="uploadPercent > 0" v-loading="uploadPercent > 0"
:element-loading-text="uploadPercent + '%'" :element-loading-text="uploadPercent + '%'"
> >
<!-- 删除图片 -->
<div <div
class="relative w-20 h-20 rounded-lg overflow-hidden group" class="relative w-20 h-20 rounded-lg overflow-hidden group"
v-for="img in form.imgUrl" v-for="img in form.imgUrl"
...@@ -82,7 +127,7 @@ ...@@ -82,7 +127,7 @@
fit="cover" fit="cover"
/> />
</div> </div>
</div> </div> -->
</div> </div>
</div> </div>
...@@ -122,7 +167,7 @@ ...@@ -122,7 +167,7 @@
:disabled="uploadPercent > 0" :disabled="uploadPercent > 0"
> >
<el-icon size="18"> <el-icon size="18">
<span v-if="!form.imgUrl.length && uploadPercent > 0"> <IEpLoading /></span> <span v-if="!imgList.length && uploadPercent > 0"> <IEpLoading /></span>
<span v-else> <IEpPicture /></span> <span v-else> <IEpPicture /></span>
</el-icon> </el-icon>
</el-button> </el-button>
...@@ -159,6 +204,7 @@ ...@@ -159,6 +204,7 @@
<el-button <el-button
type="primary" type="primary"
:disabled="disabledSubmit" :disabled="disabledSubmit"
:loading="loading"
class="px-6 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200" class="px-6 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200"
@click="handlePublish(ReleaseStatusTypeEnum.PUBLISH)" @click="handlePublish(ReleaseStatusTypeEnum.PUBLISH)"
> >
...@@ -204,6 +250,8 @@ const { ...@@ -204,6 +250,8 @@ const {
maxLength?: number maxLength?: number
}>() }>()
const loading = ref(false)
const textMap: Record< const textMap: Record<
ArticleType, ArticleType,
{ title: string; content: string; api: (data: any) => Promise<any> } { title: string; content: string; api: (data: any) => Promise<any> }
...@@ -260,7 +308,7 @@ const { play } = useAnimate( ...@@ -260,7 +308,7 @@ const { play } = useAnimate(
const [form, resetForm] = useResetData({ const [form, resetForm] = useResetData({
title: '', title: '',
content: '', content: '',
imgUrl: [] as string[], imgUrl: '',
releaseStatus: ReleaseStatusTypeEnum.PUBLISH, releaseStatus: ReleaseStatusTypeEnum.PUBLISH,
mainTagId: '', mainTagId: '',
tagList: [], tagList: [],
...@@ -268,7 +316,7 @@ const [form, resetForm] = useResetData({ ...@@ -268,7 +316,7 @@ const [form, resetForm] = useResetData({
sendTime: '', sendTime: '',
}) })
const { imgsStr, handleFileChange, handleDeleteImg, uploadPercent } = useUploadImg( const { imgList, handleFileChange, handleDeleteImg, uploadPercent } = useUploadImg(
toRef(form.value, 'imgUrl'), toRef(form.value, 'imgUrl'),
) )
...@@ -302,8 +350,8 @@ const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePractic ...@@ -302,8 +350,8 @@ const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePractic
return { return {
...form.value, ...form.value,
releaseStatus, releaseStatus,
faceUrl: form.value.imgUrl[0] || '', faceUrl: imgList.value[0] || '',
imgUrl: imgsStr.value, imgUrl: form.value.imgUrl,
tagList: [form.value.mainTagId, ...form.value.tagList].map((item, index) => ({ tagList: [form.value.mainTagId, ...form.value.tagList].map((item, index) => ({
sort: index, sort: index,
tagId: Number(item), tagId: Number(item),
...@@ -314,9 +362,17 @@ const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePractic ...@@ -314,9 +362,17 @@ const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePractic
const handlePublish = async (releaseStatus: ReleaseStatusTypeEnum) => { const handlePublish = async (releaseStatus: ReleaseStatusTypeEnum) => {
if (!validateForm()) return if (!validateForm()) return
await textMap[type].api(transformForm(releaseStatus)) loading.value = true
ElMessage.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功') try {
resetForm() await textMap[type].api(transformForm(releaseStatus))
loading.value = false
ElMessage.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功')
resetForm()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
} }
</script> </script>
......
import { uploadFile } from '@/api' import { uploadFile } from '@/api'
// 默认参数 // 类型定义
export const useUploadImg = (imgList: Ref<string[]> = ref([])) => { type BaseReturn = {
const uploadPercent = ref(0) handleFileChange: (e: Event) => Promise<void>
// 字符串拼 uploadPercent: Ref<number>
const imgsStr = computed(() => imgList.value.join(',')) handleDeleteImg: (urlStr: string) => void
}
// 传单字符串时多返回 imgList
type UseUploadImgReturnString = BaseReturn & {
imgList: ComputedRef<string[]>
}
// 传字符串数组时只返回基础
type UseUploadImgReturnArray = BaseReturn
// 直接传ref('imgs1,imgs2') 或者 ref(['img1','img2]) 传字符串的时候 会多返回一个imgList数组 便于模板使用遍历等
export function useUploadImg(imgs: Ref<string>): UseUploadImgReturnString
export function useUploadImg(imgs: Ref<string[]>): UseUploadImgReturnArray
export function useUploadImg(imgs: Ref<string> | Ref<string[]>) {
const uploadPercent = ref(0)
// 上传图片的change事件 // 上传图片的change事件
const handleFileChange = async (e: Event) => { const handleFileChange = async (e: Event) => {
try { try {
...@@ -18,7 +31,11 @@ export const useUploadImg = (imgList: Ref<string[]> = ref([])) => { ...@@ -18,7 +31,11 @@ export const useUploadImg = (imgList: Ref<string[]> = ref([])) => {
}, },
}) })
const data = await promise const data = await promise
imgList.value = [...imgList.value, data.filePath] if (Array.isArray(imgs.value)) {
imgs.value = [...imgs.value, data.filePath]
} else {
imgs.value = [...imgs.value.split(',').filter(Boolean), data.filePath].join(',')
}
} catch (error) { } catch (error) {
console.error('上传失败:', error) console.error('上传失败:', error)
} finally { } finally {
...@@ -30,14 +47,37 @@ export const useUploadImg = (imgList: Ref<string[]> = ref([])) => { ...@@ -30,14 +47,37 @@ export const useUploadImg = (imgList: Ref<string[]> = ref([])) => {
// 删除图片 // 删除图片
const handleDeleteImg = (urlStr: string) => { const handleDeleteImg = (urlStr: string) => {
imgList.value = imgList.value.filter((item) => item !== urlStr) if (Array.isArray(imgs.value)) {
imgs.value = imgs.value.filter((item) => item !== urlStr)
} else {
imgs.value =
imgs.value
.split(',')
.filter((item) => item !== urlStr)
.join(',') || ''
}
} }
return { const imgList = computed(() => {
imgsStr, if (Array.isArray(imgs.value)) {
imgList, return imgs.value
handleFileChange, } else {
uploadPercent, return imgs.value.split(',').filter(Boolean)
handleDeleteImg, }
})
if (Array.isArray(imgs.value)) {
return {
handleFileChange,
uploadPercent,
handleDeleteImg,
}
} else {
return {
handleFileChange,
uploadPercent,
handleDeleteImg,
imgList,
}
} }
} }
import emojis from './face.json'
function escapeHTML(str: string) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
export const parseEmoji = (content: string) => {
if (!content) return ''
let html = escapeHTML(content)
emojis.forEach((item) => {
const escapedName = item.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const reg = new RegExp(escapedName, 'g')
html = html.replace(
reg,
`<img
src="${item.url}"
alt="${item.name}"
class="${item.className || 'emoji_small'} inline-block align-text-bottom w-6 h-6"
/>`,
)
})
return html
}
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<span <span
v-for="tag in questionDetail.tagNameList" v-for="tag in questionDetail.tagNameList"
:key="tag" :key="tag"
class="px-2.5 py-0.5 rounded-full bg-blue-50 text-blue-500 text-xs font-semibold hover:bg-blue-100 transition-colors cursor-pointer" class="px-2.5 py-0.5 rounded-full bg-blue-50 text-blue-500 text-xs font-semibold hover:bg-blue-100 transition-colors cursor-pointer mr-2"
> >
#{{ tag }} #{{ tag }}
</span> </span>
...@@ -70,7 +70,21 @@ ...@@ -70,7 +70,21 @@
/></el-icon> /></el-icon>
</button> </button>
</div> </div>
<div
v-if="questionDetail.imgUrl"
class="mt-3 flex gap-2 flex-wrap items-center justify-start"
>
<el-image
v-for="(item, i) in questionDetail.imgUrl.split(',')"
:key="item"
:src="item"
fit="cover"
class="rounded-lg w-24 h-24 hover:scale-105 transition-transform cursor-pointer"
:preview-src-list="questionDetail.imgUrl.split(',')"
:initial-index="i"
:preview-teleported="true"
/>
</div>
<!-- 底部操作栏 --> <!-- 底部操作栏 -->
<div class="flex items-center justify-between mt-4"> <div class="flex items-center justify-between mt-4">
<div class="flex gap-3"> <div class="flex gap-3">
...@@ -84,7 +98,7 @@ ...@@ -84,7 +98,7 @@
</div> </div>
<!-- 右侧数据 --> <!-- 右侧数据 -->
<div class="flex items-center gap-6 text-slate-400 text-sm select-none"> <div class="flex items-center gap-6 text-slate-500 text-sm select-none">
<span <span
@click="handleLikeArticle" @click="handleLikeArticle"
class="hover:text-slate-600 cursor-pointer transition-colors flex items-center gap-1" class="hover:text-slate-600 cursor-pointer transition-colors flex items-center gap-1"
...@@ -112,17 +126,6 @@ ...@@ -112,17 +126,6 @@
</div> </div>
</div> </div>
<!-- 展示图片相关 --> <!-- 展示图片相关 -->
<div v-if="questionDetail.imgUrl" class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
<el-image
v-for="item in questionDetail.imgUrl.split(',')"
:key="item"
:src="item"
fit="cover"
class="rounded-lg w-full h-64 hover:scale-105 transition-transform cursor-pointer"
:preview-src-list="questionDetail.imgUrl.split(',')"
:preview-teleported="true"
/>
</div>
</div> </div>
<!-- 2. 列表控制栏 --> <!-- 2. 列表控制栏 -->
...@@ -187,24 +190,41 @@ ...@@ -187,24 +190,41 @@
>优秀回答</span >优秀回答</span
> --> > -->
</div> </div>
<!-- <div class="text-xs text-slate-400 mt-0.5 max-w-md truncate"> <!-- <div class="text-xs text-slate-500 mt-0.5 max-w-md truncate">
{{ answer.description || '暂无简介' }} {{ answer.description || '暂无简介' }}
</div> --> </div> -->
</div> </div>
</div> </div>
<!-- 赞同票数 (微小的灰色文字,增加信息密度) --> <!-- 赞同票数 (微小的灰色文字,增加信息密度) -->
<div v-if="answer.postPriseCount" class="text-xs text-slate-400 mb-2"> <div v-if="answer.postPriseCount" class="text-xs text-slate-500 mb-2">
{{ answer.postPriseCount || 0 }} 人赞同了该回答 {{ answer.postPriseCount || 0 }} 人赞同了该回答
</div> </div>
<!-- 正文 换行 --> <!-- 正文 换行 -->
<div <div
class="text-slate-800 leading-7 text-[15px] mb-4 rich-text-content break-all" class="text-slate-800 leading-7 text-[15px] mb-4 rich-text-content break-all"
v-html="answer.content" v-html="parseEmoji(answer.content)"
></div> ></div>
<!-- 评论图片列表 -->
<div class="flex flex-wrap gap-3 mb-3">
<div
v-for="(img, imgIndex) in answer.imgUrl.split(',').filter(Boolean)"
:key="imgIndex"
class="w-24 h-24 rounded-lg overflow-hidden mb-2"
>
<el-image
:src="img"
:preview-teleported="true"
class="w-full h-full object-cover"
:preview-src-list="answer.imgUrl.split(',').filter(Boolean)"
:initial-index="imgIndex"
fit="cover"
/>
</div>
</div>
<div class="text-xs text-slate-400 mb-4"> <div class="text-xs text-slate-500 mb-3">
发布于 {{ dayjs(answer.createTime * 1000).format('YYYY-MM-DD HH:mm') }} 发布于 {{ dayjs(answer.createTime * 1000).format('YYYY-MM-DD HH:mm') }}
</div> </div>
...@@ -294,6 +314,7 @@ import dayjs from 'dayjs' ...@@ -294,6 +314,7 @@ import dayjs from 'dayjs'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { jumpToUserHomePage } from '@/utils' import { jumpToUserHomePage } from '@/utils'
import { parseEmoji } from '@/utils/emoji'
const userStore = useUserStore() const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore) const { userInfo } = storeToRefs(userStore)
......
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