Files
vtsuru.live/src/views/view/QuestionBoxView.vue
Megghy 968c34f57a feat: 更新依赖和增强动态表单功能
- 在 package.json 中添加 hammerjs 和 tui-image-editor 依赖
- 在 DynamicForm.vue 中引入并实现装饰性图片功能,支持图片上传、删除和属性调整
- 优化颜色处理逻辑,支持 RGBA 格式
- 更新常量和类型定义,增强代码可读性和可维护性
2025-04-29 05:31:00 +08:00

892 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { QAInfo, UserInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import { AVATAR_URL, QUESTION_API_URL, TURNSTILE_KEY } from '@/data/constants'
import GraphemeSplitter from 'grapheme-splitter'
import {
NAlert,
NAvatar,
NButton,
NCard,
NCheckbox,
NDivider,
NEmpty,
NImage,
NInput,
NList,
NListItem,
NSelect,
NSpace,
NTag,
NText,
NTime,
NTooltip,
NUpload,
UploadFileInfo,
useMessage,
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import VueTurnstile from 'vue-turnstile'
import { useRoute } from 'vue-router'
const { biliInfo, userInfo } = defineProps<{
biliInfo: any | undefined
userInfo: UserInfo | undefined
}>()
// 状态变量
const message = useMessage()
const accountInfo = useAccount()
const questionMessage = ref('') // 提问内容
const fileList = ref<UploadFileInfo[]>([]) // 上传图片列表
const publicQuestions = ref<QAInfo[]>([]) // 公开提问列表
const tags = ref<string[]>([]) // 标签列表
const selectedTag = ref<string | null>(null) // 选中的标签
const isAnonymous = ref(true) // 是否匿名提问
const isSending = ref(false) // 是否正在发送
const isGetting = ref(true) // 是否正在获取数据
// 验证码相关
const token = ref('')
const turnstile = ref()
const route = useRoute()
// 防刷控制
const nextSendQuestionTime = ref(Date.now())
const minSendQuestionTime = 30 * 1000 // 30秒冷却时间
// 字符分割器(用于正确计算表情符号等Unicode字符)
const splitter = new GraphemeSplitter()
// 计算属性
const isSelf = computed(() => {
return userInfo?.id === accountInfo.value?.id
})
// 计算字符数量
function countGraphemes(value: string) {
return splitter.countGraphemes(value)
}
// 发送提问
async function SendQuestion() {
// 内容长度检查
if (countGraphemes(questionMessage.value) < 3) {
message.error('内容最少需要3个字')
return
}
// 冷却时间检查
if (nextSendQuestionTime.value > Date.now()) {
message.error('冷却中, 剩余 ' + Math.ceil((nextSendQuestionTime.value - Date.now()) / 1000) + '秒')
return
}
isSending.value = true
await QueryPostAPI<QAInfo>(
QUESTION_API_URL + 'send',
{
Target: userInfo?.id,
IsAnonymous: !accountInfo.value || isAnonymous.value,
Message: questionMessage.value,
ImageBase64: fileList.value?.length > 0 ? await getBase64(fileList.value[0].file) : undefined,
Tag: selectedTag.value,
},
[['Turnstile', token.value]],
)
.then((data) => {
if (data.code == 200) {
message.success('成功发送棉花糖')
questionMessage.value = ''
fileList.value = []
nextSendQuestionTime.value = Date.now() + minSendQuestionTime
} else {
message.error(data.message)
}
})
.catch((err) => {
message.error('发送失败')
})
.finally(() => {
isSending.value = false
turnstile.value?.reset()
})
}
// 转换文件为Base64
function getBase64(file: File | undefined | null) {
if (!file) return null
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result?.toString().split(',')[1])
reader.onerror = (error) => reject(error)
})
}
// 文件列表变更处理
function OnFileListChange(files: UploadFileInfo[]) {
if (files.length == 1) {
const file = files[0]
// 文件大小检查
if ((file.file?.size ?? 0) > 10 * 1024 * 1024) {
message.error('文件大小不能超过10MB')
fileList.value = []
}
}
}
// 获取公开提问列表
function getPublicQuestions() {
isGetting.value = true
QueryGetAPI<QAInfo[]>(QUESTION_API_URL + 'get-public', {
id: userInfo?.id,
})
.then((data) => {
if (data.code == 200) {
publicQuestions.value = data.data
} else {
message.error('获取公开提问失败:' + data.message)
}
})
.catch((err) => {
message.error('获取公开提问失败')
})
.finally(() => {
isGetting.value = false
})
}
// 获取标签列表
function getTags() {
isGetting.value = true
QueryGetAPI<string[]>(QUESTION_API_URL + 'get-tags', {
id: userInfo?.id,
})
.then((data) => {
if (data.code == 200) {
// 处理标签数据
if (userInfo?.id == accountInfo.value?.id) {
// 自己查看自己的标签时,需要从对象中提取名称
tags.value = data.data.map((tag: any) => {
// 直接访问name属性避免不必要的JSON序列化和解析
return typeof tag === 'object' && tag !== null ? tag.name : tag
})
} else {
// 查看他人标签时直接使用返回数据
tags.value = data.data
}
} else {
message.error('获取标签失败:' + data.message)
}
})
.catch((err) => {
message.error('获取标签失败: ' + err)
})
.finally(() => {
isGetting.value = false
// 检查 URL 参数中的 tag
const tagFromQuery = route.query.tag as string | undefined
if (tagFromQuery && tags.value.includes(tagFromQuery)) {
selectedTag.value = tagFromQuery
}
})
}
// 标签选择处理
function onSelectTag(tag: string) {
// 切换选中状态
selectedTag.value = selectedTag.value === tag ? null : tag
}
// 生命周期钩子
onMounted(() => {
getPublicQuestions()
getTags()
})
onUnmounted(() => {
// 清理验证码资源
turnstile.value?.remove()
})
</script>
<template>
<div class="question-box-container">
<!-- 提问表单 -->
<transition
name="fade-slide-down"
appear
>
<NCard
embedded
class="question-form-card"
:class="{ 'self-user': isSelf }"
>
<NSpace vertical>
<!-- 话题选择区域 -->
<transition
name="fade-scale"
appear
>
<NCard
v-if="tags.length > 0"
title="投稿话题 (可选)"
size="small"
class="topic-card"
>
<transition-group
name="tag-list"
tag="div"
class="tag-container"
>
<NTag
v-for="tag in tags"
:key="tag"
class="tag-item"
:bordered="false"
:type="selectedTag === tag ? 'primary' : 'default'"
:clearable="false"
@click="onSelectTag(tag)"
>
{{ tag }}
</NTag>
</transition-group>
</NCard>
</transition>
<!-- 提问内容区域 -->
<div class="question-input-area">
<NInput
v-model:value="questionMessage"
:disabled="isSelf"
show-count
maxlength="5000"
type="textarea"
:count-graphemes="countGraphemes"
class="question-textarea"
placeholder="在这里输入您的问题..."
/>
<transition
name="fade-scale"
>
<NUpload
v-model:file-list="fileList"
:max="1"
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
list-type="image-card"
:disabled="!accountInfo.id || isSelf"
:default-upload="false"
class="image-upload"
@update:file-list="OnFileListChange"
>
<div class="upload-trigger">
<div class="upload-icon">
+
</div>
<span>上传图片</span>
</div>
</NUpload>
</transition>
</div>
<NDivider class="form-divider" />
<!-- 提示信息 -->
<transition
name="fade"
appear
>
<NAlert
v-if="!accountInfo.id && !isSelf"
type="warning"
class="login-alert"
>
只有注册用户才能够上传图片
</NAlert>
</transition>
<!-- 匿名选项 -->
<transition
name="fade"
appear
>
<NSpace
v-if="accountInfo.id"
vertical
class="anonymous-option"
>
<NCheckbox
v-model:checked="isAnonymous"
:disabled="isSelf"
label="匿名提问"
/>
<NDivider class="form-divider" />
</NSpace>
</transition>
<!-- 操作按钮 -->
<div class="action-buttons">
<NButton
:disabled="isSelf"
type="primary"
:loading="isSending || !token"
class="send-button"
@click="SendQuestion"
>
发送
</NButton>
<NButton
:disabled="isSelf || !accountInfo.id"
type="info"
class="my-questions-button"
@click="$router.push({ name: 'manage-questionBox', query: { send: '1' } })"
>
我发送的
</NButton>
</div>
<!-- 验证码 -->
<div class="turnstile-container">
<VueTurnstile
ref="turnstile"
v-model="token"
:site-key="TURNSTILE_KEY"
theme="auto"
/>
</div>
<!-- 错误提示 -->
<transition
name="fade-slide-up"
appear
>
<NAlert
v-if="isSelf"
type="warning"
class="self-alert"
>
不能给自己提问
</NAlert>
</transition>
</NSpace>
</NCard>
</transition>
<!-- 公开回复列表 -->
<transition
name="fade"
appear
>
<div>
<NDivider class="public-divider">
<div class="divider-content">
公开回复
</div>
</NDivider>
<transition-group
name="list-fade"
tag="div"
class="questions-list-container"
>
<NList
v-if="publicQuestions.length > 0"
class="questions-list"
>
<NListItem
v-for="item in publicQuestions"
:key="item.id"
class="question-list-item"
>
<NCard
:embedded="!item.isReaded"
hoverable
size="small"
class="question-card"
:class="{ 'unread': !item.isReaded }"
>
<!-- 问题头部 -->
<template #header>
<NSpace
:size="0"
align="center"
class="question-header"
>
<NText
depth="3"
class="time-text"
>
<NTooltip>
<template #trigger>
<NTime
:time="item.sendAt"
:to="Date.now()"
type="relative"
/>
</template>
<NTime :time="item.sendAt" />
</NTooltip>
</NText>
<div
v-if="item.tag"
class="question-tag"
>
<NTag
size="small"
type="info"
>
{{ item.tag }}
</NTag>
</div>
</NSpace>
</template>
<!-- 问题内容 -->
<NCard class="question-content">
<div class="question-message">
{{ item.question.message }}
</div>
<div
v-if="item.question.image"
class="question-image-container"
>
<NImage
:src="item.question.image"
class="question-image"
lazy
object-fit="contain"
/>
</div>
</NCard>
<!-- 回答内容 -->
<template
v-if="item.answer"
#footer
>
<div class="answer-container">
<NSpace
align="center"
:wrap="false"
class="answer-content"
>
<NAvatar
:src="AVATAR_URL + userInfo?.biliId + '?size=64'"
circle
class="answer-avatar"
:img-props="{ referrerpolicy: 'no-referrer' }"
/>
<NDivider
vertical
class="answer-divider"
/>
<NText class="answer-text">
{{ item.answer?.message }}
</NText>
</NSpace>
</div>
</template>
</NCard>
</NListItem>
</NList>
<NEmpty
v-else
class="empty-state"
/>
</transition-group>
</div>
</transition>
<NDivider />
</div>
</template>
<style scoped>
.n-list {
background-color: transparent;
}
.question-box-container {
max-width: 700px;
margin: 0 auto;
position: relative;
width: 100%;
padding: 0 16px;
}
/* 卡片样式 */
.question-form-card {
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
overflow: hidden;
}
.question-form-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
.self-user {
border-left: 4px solid #f5222d;
}
/* 话题选择卡片 */
.topic-card {
border-radius: 8px;
overflow: hidden;
}
.tag-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-item {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
border-radius: 16px;
}
.tag-item:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 提问输入区域 */
.question-textarea {
flex: 1;
border-radius: 8px;
transition: all 0.3s ease;
width: 100% !important; /* 使用 !important 确保不被其他样式覆盖 */
min-height: 100px; /* 设置最小高度 */
resize: vertical; /* 允许垂直调整大小 */
}
/* 设置 naive-ui 内部元素样式 */
.question-textarea :deep(.n-input__textarea) {
min-width: 100% !important;
width: 100% !important;
}
.question-textarea :deep(.n-input__textarea-el) {
min-width: 100% !important;
width: 100% !important;
}
/* 确保输入框容器占满可用空间 */
.question-input-area {
display: flex;
flex-direction: column;
gap: 16px;
margin: 16px 0;
width: 100%;
}
@media (min-width: 640px) {
.question-input-area {
flex-direction: row;
align-items: flex-start;
}
/* 在水平布局中设置输入框区域的最小宽度 */
.question-textarea {
min-width: 75%; /* 占据至少75%的宽度 */
}
}
.image-upload {
min-width: 112px;
}
.upload-trigger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
opacity: 0.8;
transition: all 0.3s ease;
}
.upload-trigger:hover {
opacity: 1;
transform: scale(1.05);
}
.upload-icon {
font-size: 24px;
margin-bottom: 4px;
}
/* 分隔线 */
.form-divider {
margin: 10px 0;
opacity: 0.6;
}
/* 警告提示 */
.login-alert,
.self-alert {
border-radius: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 213, 79, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(255, 213, 79, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 213, 79, 0);
}
}
/* 匿名选项 */
.anonymous-option {
margin: 8px 0;
}
/* 操作按钮 */
.action-buttons {
display: flex;
justify-content: center;
gap: 16px;
margin: 8px 0 16px 0;
}
.send-button,
.my-questions-button {
min-width: 100px;
transition: all 0.3s ease;
border-radius: 20px;
}
.send-button:not(:disabled):hover,
.my-questions-button:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* 验证码容器 */
.turnstile-container {
display: flex;
justify-content: center;
margin: 8px 0;
}
/* 公开回复部分 */
.public-divider {
margin: 32px 0 24px;
position: relative;
}
.divider-content {
font-weight: bold;
color: #36ad6a;
background-image: linear-gradient(90deg, #36ad6a, #18a058);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
display: inline-block;
padding: 0 8px;
position: relative;
}
.questions-list-container {
position: relative;
}
.questions-list {
border-radius: 12px;
overflow: hidden;
}
.question-list-item {
margin-bottom: 16px;
}
.question-card {
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
}
.question-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.unread {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
}
.question-header {
padding: 8px 0;
}
.time-text {
font-size: 12px;
opacity: 0.8;
}
.question-tag {
margin-left: 8px;
}
.question-content {
text-align: center;
padding: 16px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 6px;
}
.question-message {
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 12px;
}
.question-image-container {
display: flex;
justify-content: center;
margin-top: 12px;
}
.question-image {
max-height: 200px;
border-radius: 8px;
transition: all 0.3s ease;
}
.question-image:hover {
transform: scale(1.02);
}
.answer-container {
padding: 12px;
background-color: rgba(24, 160, 88, 0.06);
border-radius: 8px;
margin-top: 8px;
}
.answer-content {
gap: 12px;
}
.answer-avatar {
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 2px solid #fff;
transition: all 0.3s ease;
}
.answer-avatar:hover {
transform: scale(1.05);
}
.answer-divider {
height: 24px;
margin: 0 4px;
}
.answer-text {
font-size: 16px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.empty-state {
padding: 24px;
opacity: 0.7;
}
/* 过渡动效 */
/* 淡入淡出 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 下滑淡入 */
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.5s ease;
}
.fade-slide-down-enter-from,
.fade-slide-down-leave-to {
opacity: 0;
transform: translateY(-20px);
}
/* 上滑淡入 */
.fade-slide-up-enter-active,
.fade-slide-up-leave-active {
transition: all 0.5s ease;
}
.fade-slide-up-enter-from,
.fade-slide-up-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 缩放淡入 */
.fade-scale-enter-active,
.fade-scale-leave-active {
transition: all 0.5s ease;
}
.fade-scale-enter-from,
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.9);
}
/* 标签列表过渡 */
.tag-list-move {
transition: all 0.5s ease;
}
.tag-list-enter-active,
.tag-list-leave-active {
transition: all 0.5s ease;
}
.tag-list-enter-from,
.tag-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 问题列表过渡 */
.list-fade-move {
transition: transform 0.5s ease;
}
.list-fade-enter-active,
.list-fade-leave-active {
transition: all 0.5s ease;
}
.list-fade-enter-from,
.list-fade-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>