feat: Implement local question saving feature for unauthenticated users

- Added functionality to automatically save questions sent by unauthenticated users to local storage using VueUse's useStorage.
- Introduced a local history button to display the number of saved questions.
- Created a drawer component to view, delete, and clear local question records.
- Enhanced the question form with options for anonymous name and email input.
- Updated UI components and styles for better user experience.
- Added tests and documentation for the new feature.
This commit is contained in:
Megghy
2025-10-10 14:35:56 +08:00
parent c9ec427692
commit 4ad9766043
15 changed files with 941 additions and 57 deletions

View File

@@ -391,6 +391,8 @@ export interface QAInfo {
tag?: string
reviewResult?: QAReviewInfo
anonymousName?: string
anonymousEmail?: string
}
export interface LotteryUserInfo {
name: string
@@ -829,7 +831,7 @@ export interface ResponsePointHisrotyModel {
createAt: number
count: number
extra?: any
extra?: any // Use 时包含: { user, goods, isDiscontinued, remark }; Manual 时包含: { user, reason }; Danmaku 时包含: { user, danmaku }; CheckIn 时包含: { user }
}
export enum PointFrom {

4
src/components.d.ts vendored
View File

@@ -19,13 +19,17 @@ declare module 'vue' {
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NBadge: typeof import('naive-ui')['NBadge']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTime: typeof import('naive-ui')['NTime']
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']

View File

@@ -33,7 +33,7 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
{
title: '时间',
key: 'createAt',
sorter: 'default',
sorter: (row1: ResponsePointHisrotyModel, row2: ResponsePointHisrotyModel) => row1.createAt - row2.createAt,
render: (row: ResponsePointHisrotyModel) => {
return h(NTooltip, null, {
trigger: () => h(NTime, { time: row.createAt, type: 'relative' }),
@@ -44,11 +44,13 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
{
title: '积分变动',
key: 'point',
sorter: (row1: ResponsePointHisrotyModel, row2: ResponsePointHisrotyModel) => row1.point - row2.point,
render: (row: ResponsePointHisrotyModel) => {
const point = Number(row.point.toFixed(1))
return h(
NText,
{ style: { color: row.point < 0 ? 'red' : 'green' } },
() => (row.point < 0 ? '' : '+') + row.point,
{ style: { color: point < 0 ? 'red' : 'green' } },
() => (point < 0 ? '' : '+') + point,
)
},
},
@@ -205,18 +207,29 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
case PointFrom.Use:
return h(NFlex, { align: 'center' }, () => [
h(NTag, { type: 'success', size: 'small', style: { margin: '0' }, strong: true }, () => '兑换'),
h(
NButton,
{
text: true,
type: 'info',
onClick: () => {
currentGoods.value = row.extra
showGoodsModal.value = true
},
},
() => row.extra?.name,
),
row.extra?.goods
? h(
NButton,
{
text: true,
type: 'info',
onClick: () => {
currentGoods.value = row.extra?.goods
showGoodsModal.value = true
},
},
() => row.extra?.goods?.name,
)
: h(NText, { depth: 3, italic: true }, () => '(商品已删除)'),
row.extra?.isDiscontinued
? h(NTag, { type: 'error', size: 'tiny', bordered: false }, () => '已下架')
: null,
row.extra?.remark
? h(NTooltip, null, {
trigger: () => h(NTag, { type: 'info', size: 'tiny', bordered: false }, () => '留言'),
default: () => row.extra.remark,
})
: null,
])
}
return null

View File

@@ -184,7 +184,7 @@ const orderColumn: DataTableColumns<OrderType> = [
{
title: '时间',
key: 'time',
sorter: 'default',
sorter: (row1: OrderType, row2: OrderType) => row1.createAt - row2.createAt,
minWidth: 80,
render: (row: OrderType) => {
return h(NTooltip, null, {
@@ -196,7 +196,10 @@ const orderColumn: DataTableColumns<OrderType> = [
{
title: '使用积分',
key: 'point',
sorter: 'default',
sorter: (row1: OrderType, row2: OrderType) => row1.point - row2.point,
render: (row: OrderType) => {
return Number(row.point.toFixed(1))
},
},
{
title: '订单状态',

View File

@@ -116,7 +116,7 @@ const router = createRouter({
})
router.beforeEach((to, from, next) => {
useLoadingBarStore().loadingBar?.start()
// 保留 as 参数(如果存在)
if (from.query.as && !to.query.as) {
next({
@@ -128,7 +128,7 @@ router.beforeEach((to, from, next) => {
})
return
}
next()
})
router.afterEach(() => {

View File

@@ -80,7 +80,7 @@ const orderStats = computed(() => {
completed: orders.value.filter(o => o.status === PointOrderStatus.Completed).length,
physical: orders.value.filter(o => o.type === GoodsTypes.Physical).length,
virtual: orders.value.filter(o => o.type === GoodsTypes.Virtual).length,
totalPoints: orders.value.reduce((sum, o) => sum + o.point, 0),
totalPoints: Number(orders.value.reduce((sum, o) => sum + o.point, 0).toFixed(1)),
filteredCount: filteredOrders.value.length,
}
})
@@ -200,8 +200,8 @@ function exportData() {
: '无',
礼物名: gift?.name ?? '已删除',
礼物数量: s.count,
礼物单价: gift?.price,
礼物总价: s.point,
礼物单价: gift?.price ? Number(gift.price.toFixed(1)) : 0,
礼物总价: Number(s.point.toFixed(1)),
快递公司: s.expressCompany,
快递单号: s.trackingNumber,
备注: s.remark ?? '',

View File

@@ -146,7 +146,7 @@ async function givePoint() {
if (data.code == 200) {
message.success('添加成功')
showAddPointModal.value = false
props.user.point += addPointCount.value
props.user.point = Number((props.user.point + addPointCount.value).toFixed(1))
// 重新加载积分历史
setTimeout(async () => {
@@ -227,7 +227,7 @@ onMounted(async () => {
</NDescriptionsItem>
<NDescriptionsItem label="积分">
{{ user.point }}
{{ Number(user.point.toFixed(1)) }}
</NDescriptionsItem>
<NDescriptionsItem

View File

@@ -119,12 +119,14 @@ const filteredUsers = computed(() => {
// 用户统计
const userStats = computed(() => {
const totalPoints = users.value.reduce((sum, u) => sum + u.point, 0)
const avgPoints = users.value.length > 0 ? users.value.reduce((sum, u) => sum + u.point, 0) / users.value.length : 0
return {
total: users.value.length,
authed: users.value.filter(u => u.isAuthed).length,
totalPoints: users.value.reduce((sum, u) => sum + u.point, 0),
totalPoints: Number(totalPoints.toFixed(1)),
totalOrders: users.value.reduce((sum, u) => sum + (u.orderCount || 0), 0),
avgPoints: users.value.length > 0 ? Math.round(users.value.reduce((sum, u) => sum + u.point, 0) / users.value.length) : 0,
avgPoints: Number(avgPoints.toFixed(1)),
filtered: filteredUsers.value.length,
}
})
@@ -197,9 +199,10 @@ function formatNumber(num: number) {
return num.toLocaleString('zh-CN')
}
// 渲染积分,添加千位符并加粗
// 渲染积分,添加千位符并加粗,保留一位小数
function renderPoint(num: number) {
return h(NText, { strong: true }, { default: () => formatNumber(num) })
const formattedNum = Number(num.toFixed(1))
return h(NText, { strong: true }, { default: () => formatNumber(formattedNum) })
}
// 数据表格列定义
@@ -219,7 +222,7 @@ const column: DataTableColumns<ResponsePointUserModel> = [
{
title: '积分',
key: 'point',
sorter: 'default',
sorter: (row1: ResponsePointUserModel, row2: ResponsePointUserModel) => row1.point - row2.point,
render: (row: ResponsePointUserModel) => renderPoint(row.point),
},
{
@@ -230,7 +233,7 @@ const column: DataTableColumns<ResponsePointUserModel> = [
{
title: '最后更新于',
key: 'updateAt',
sorter: 'default',
sorter: (row1: ResponsePointUserModel, row2: ResponsePointUserModel) => row1.updateAt - row2.updateAt,
render: (row: ResponsePointUserModel) => renderTime(row.updateAt),
},
{
@@ -374,7 +377,7 @@ function exportData() {
用户ID: user.info.userId || user.info.openId,
用户名: user.info.name || '未知',
认证状态: user.isAuthed ? '已认证' : '未认证',
积分: user.point,
积分: Number(user.point.toFixed(1)),
订单数量: user.orderCount || 0,
最后更新时间: format(user.updateAt, 'yyyy-MM-dd HH:mm:ss'),
}

View File

@@ -87,6 +87,12 @@ watch(searchKeyword, (newVal) => {
// --- 计算属性 ---
// 格式化积分显示,保留一位小数
const formattedCurrentPoint = computed(() => {
if (currentPoint.value < 0) return currentPoint.value
return Number(currentPoint.value.toFixed(1))
})
// 地址选项,用于地址选择器
const addressOptions = computed(() => {
if (!biliAuth.value.id) return []
@@ -264,7 +270,7 @@ async function buyGoods() {
// 确认对话框
dialog.warning({
title: '确认兑换',
content: `确定要花费 ${currentGoods.value!.price * buyCount.value} 积分兑换 ${buyCount.value} 个 "${currentGoods.value!.name}" 吗?`,
content: `确定要花费 ${Number((currentGoods.value!.price * buyCount.value).toFixed(1))} 积分兑换 ${buyCount.value} 个 "${currentGoods.value!.name}" 吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
@@ -281,7 +287,7 @@ async function buyGoods() {
if (data.code === 200) {
message.success('兑换成功')
// 更新本地积分显示
currentPoint.value -= currentGoods.value!.price * buyCount.value
currentPoint.value = Number((currentPoint.value - currentGoods.value!.price * buyCount.value).toFixed(1))
// 显示成功对话框
dialog.success({
title: '成功',
@@ -426,7 +432,7 @@ onMounted(async () => {
v-if="currentPoint >= 0"
class="point-info"
>
你在本直播间的积分: <strong>{{ currentPoint }}</strong>
你在本直播间的积分: <strong>{{ formattedCurrentPoint }}</strong>
</NText>
<NText
v-else
@@ -802,9 +808,9 @@ onMounted(async () => {
确认兑换
</NButton>
<NText depth="2">
所需积分: {{ currentGoods.price * buyCount }}
所需积分: {{ Number((currentGoods.price * buyCount).toFixed(1)) }}
<NDivider vertical />
当前积分: {{ currentPoint >= 0 ? currentPoint : '加载中' }}
当前积分: {{ currentPoint >= 0 ? formattedCurrentPoint : '加载中' }}
</NText>
</NFlex>
</NModal>

View File

@@ -42,12 +42,13 @@ const filteredOrders = computed(() => {
// 订单统计
const orderStats = computed(() => {
const totalPoints = orders.value.reduce((sum, o) => sum + o.point, 0)
return {
total: orders.value.length,
pending: orders.value.filter(o => o.status === PointOrderStatus.Pending).length,
shipped: orders.value.filter(o => o.status === PointOrderStatus.Shipped).length,
completed: orders.value.filter(o => o.status === PointOrderStatus.Completed).length,
totalPoints: orders.value.reduce((sum, o) => sum + o.point, 0),
totalPoints: Number(totalPoints.toFixed(1)),
}
})

View File

@@ -23,10 +23,13 @@ const dateRange = ref<[number, number] | null>(null)
// 积分历史统计
const historyStats = computed(() => {
const totalIncrease = history.value.filter(h => h.point > 0).reduce((sum, h) => sum + h.point, 0)
const totalDecrease = Math.abs(history.value.filter(h => h.point < 0).reduce((sum, h) => sum + h.point, 0))
return {
total: history.value.length,
totalIncrease: history.value.filter(h => h.point > 0).reduce((sum, h) => sum + h.point, 0),
totalDecrease: Math.abs(history.value.filter(h => h.point < 0).reduce((sum, h) => sum + h.point, 0)),
totalIncrease: Number(totalIncrease.toFixed(1)),
totalDecrease: Number(totalDecrease.toFixed(1)),
netIncrease: Number((totalIncrease - totalDecrease).toFixed(1)),
fromManual: history.value.filter(h => h.from === PointFrom.Manual).length,
fromDanmaku: history.value.filter(h => h.from === PointFrom.Danmaku).length,
fromCheckIn: history.value.filter(h => h.from === PointFrom.CheckIn).length,
@@ -140,7 +143,7 @@ function exportHistoryData() {
filteredHistory.value.map((item) => {
return {
时间: format(item.createAt, 'yyyy-MM-dd HH:mm:ss'),
积分变化: item.point,
积分变化: Number(item.point.toFixed(1)),
来源: pointFromText[item.from] || '未知',
主播: item.extra?.user?.name || '-',
数量: item.count || '-',
@@ -206,7 +209,7 @@ function exportHistoryData() {
</div>
<div class="stat-item">
<div class="stat-value primary">
{{ historyStats.totalIncrease - historyStats.totalDecrease }}
{{ historyStats.netIncrease }}
</div>
<div class="stat-label">
净增加

View File

@@ -49,8 +49,10 @@ const isLoading = ref(false)
const userAgree = ref(false)
const showAddressModal = ref(false)
const showAgreementModal = ref(false)
const showAddAccountModal = ref(false)
const formRef = ref()
const currentAddress = ref<AddressInfo>()
const authCodeInput = ref('')
// 本地存储区域数据
const areas = useStorage<{
@@ -255,11 +257,53 @@ function logout() {
useAuth.logout()
}
// 添加账号
async function addAccount() {
if (!authCodeInput.value.trim()) {
message.warning('请输入登录链接或authcode')
return
}
isLoading.value = true
try {
let authCode = authCodeInput.value.trim()
// 检查是否是完整的登录链接
const urlMatch = authCode.match(/[?&]auth=([^&]+)/)
if (urlMatch) {
authCode = urlMatch[1]
}
// 验证authCode格式这里假设是token格式
if (!authCode) {
message.error('无效的authcode格式')
return
}
// 尝试使用authCode登录
await useAuth.setCurrentAuth(authCode)
// 检查是否登录成功
if (useAuth.isInvalid) {
message.error('无效的authcode请检查后重试')
} else {
message.success('账号添加成功')
showAddAccountModal.value = false
authCodeInput.value = ''
}
} catch (err) {
handleApiError('添加账号', err)
} finally {
isLoading.value = false
}
}
// 提供给父组件调用的重置方法
function reset() {
// 重置表单数据或其他状态
currentAddress.value = {} as AddressInfo
userAgree.value = false
authCodeInput.value = ''
// 可能还需要重置其他状态
}
@@ -375,17 +419,26 @@ defineExpose({
vertical
:gap="12"
>
<NPopconfirm @positive-click="logout">
<template #trigger>
<NButton
type="warning"
size="small"
>
登出当前账号
</NButton>
</template>
确定要登出吗?
</NPopconfirm>
<NFlex :gap="8">
<NPopconfirm @positive-click="logout">
<template #trigger>
<NButton
type="warning"
size="small"
>
登出当前账号
</NButton>
</template>
确定要登出吗?
</NPopconfirm>
<NButton
type="primary"
size="small"
@click="showAddAccountModal = true"
>
添加账号
</NButton>
</NFlex>
<NDivider style="margin: 8px 0">
切换账号
</NDivider>
@@ -419,7 +472,7 @@ defineExpose({
type="success"
size="small"
>
当前账号
当前
</NTag>
<NText strong>
{{ item.name }}
@@ -587,6 +640,46 @@ defineExpose({
<UserAgreement />
</NScrollbar>
</NModal>
<NModal
v-model:show="showAddAccountModal"
preset="card"
style="width: 600px; max-width: 90vw"
title="添加账号"
>
<NSpin :show="isLoading">
<NFlex
vertical
:gap="12"
>
<NText depth="3">
请输入登录链接或authcode
</NText>
<NInput
v-model:value="authCodeInput"
type="textarea"
placeholder="可以直接粘贴完整的登录链接例如\nhttps://example.com/bili-user?auth=xxxxxx\n\n或者只粘贴authcode\nxxxxxx"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
<NFlex
justify="end"
:gap="12"
>
<NButton
@click="showAddAccountModal = false"
>
取消
</NButton>
<NButton
type="primary"
:loading="isLoading"
@click="addAccount"
>
添加
</NButton>
</NFlex>
</NFlex>
</NSpin>
</NModal>
</template>
<style scoped>
@@ -608,7 +701,8 @@ defineExpose({
}
.current-account {
background-color: var(--primary-color-hover);
background-color: rgba(24, 160, 88, 0.1);
border-left: 3px solid var(--success-color);
}
/* 移动端优化 */

View File

@@ -1,14 +1,19 @@
<script setup lang="ts">
import type { QAInfo, Setting_QuestionBox, UserInfo } from '@/api/api-models'
import { AddCircle24Regular, DismissCircle24Regular } from '@vicons/fluent'
import { AddCircle24Regular, DismissCircle24Regular, History24Regular } from '@vicons/fluent'
import GraphemeSplitter from 'grapheme-splitter'
import {
NAlert,
NAvatar,
NBadge,
NButton,
NCard,
NCheckbox,
NCollapse,
NCollapseItem,
NDivider,
NDrawer,
NDrawerContent,
NEmpty,
NIcon,
NImage,
@@ -25,6 +30,7 @@ import {
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useStorage } from '@vueuse/core'
import VueTurnstile from 'vue-turnstile'
import { useAccount } from '@/api/account'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
@@ -36,11 +42,34 @@ const { biliInfo, userInfo } = defineProps<{
userInfo: UserInfo | undefined
}>()
// 本地提问历史接口
interface LocalQuestion {
id: string
targetUserId: number
targetUserName: string
message: string
tag: string | null
anonymousName: string
anonymousEmail: string
hasImage: boolean
sendAt: number
}
// 基础状态变量
const message = useMessage()
const accountInfo = useAccount()
const route = useRoute()
const splitter = new GraphemeSplitter()
const isQuestionFormExpanded = ref(false)
// 本地提问历史
const localQuestions = useStorage<LocalQuestion[]>('vtsuru-local-questions', [], undefined, {
serializer: {
read: (v: any) => v ? JSON.parse(v) : [],
write: (v: any) => JSON.stringify(v),
},
})
const showLocalQuestionsDrawer = ref(false)
// 问题相关状态
const questionMessage = ref('')
@@ -54,6 +83,8 @@ const token = ref('')
const turnstile = ref()
const nextSendQuestionTime = ref(Date.now())
const minSendQuestionTime = 30 * 1000
const anonymousName = ref('')
const anonymousEmail = ref('')
// 图片上传相关状态
const targetUserSetting = ref<Setting_QuestionBox | null>(null)
@@ -76,6 +107,12 @@ function countGraphemes(value: string) {
return splitter.countGraphemes(value)
}
function isValidEmail(email: string): boolean {
if (!email) return true // 空邮箱是允许的
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
// 图片处理公共方法
function validateImageFile(file: File): { valid: boolean, message?: string } {
if (file.size > MAX_FILE_SIZE) {
@@ -234,6 +271,12 @@ async function SendQuestion() {
return
}
// 验证邮箱格式
if (anonymousEmail.value && !isValidEmail(anonymousEmail.value)) {
message.error('邮箱格式不正确')
return
}
isSending.value = true
let uploadedFileIds: number[] = []
let imagePayload: { id: number }[] | undefined
@@ -278,6 +321,8 @@ async function SendQuestion() {
Tag: selectedTag.value,
Images: imagePayload,
ImageTokens: tokenPayload ? [tokenPayload] : undefined,
AnonymousName: !isUserLoggedIn.value && anonymousName.value ? anonymousName.value : undefined,
AnonymousEmail: !isUserLoggedIn.value && anonymousEmail.value ? anonymousEmail.value : undefined,
}
const data = await QueryPostAPI<QAInfo>(
@@ -288,11 +333,31 @@ async function SendQuestion() {
if (data.code == 200) {
message.success('成功发送棉花糖')
// 如果是未登录用户,保存到本地历史
if (!isUserLoggedIn.value && userInfo) {
const localQuestion: LocalQuestion = {
id: `local-${Date.now()}-${Math.random().toString(36).substring(7)}`,
targetUserId: userInfo.id,
targetUserName: userInfo.name || '未知用户',
message: questionMessage.value,
tag: selectedTag.value,
anonymousName: anonymousName.value,
anonymousEmail: anonymousEmail.value,
hasImage: !!anonymousImageToken.value,
sendAt: Date.now(),
}
localQuestions.value = [localQuestion, ...localQuestions.value]
}
questionMessage.value = ''
anonymousName.value = ''
anonymousEmail.value = ''
removeAnonymousImage()
clearAllLoggedInImages()
nextSendQuestionTime.value = Date.now() + minSendQuestionTime
getPublicQuestions()
isQuestionFormExpanded.value = false
} else {
message.error(data.message || '发送失败')
if (tokenPayload && (data.message.includes('token') || data.code === 400)) {
@@ -365,6 +430,17 @@ function onSelectTag(tag: string) {
selectedTag.value = selectedTag.value === tag ? null : tag
}
// 本地提问历史管理
function deleteLocalQuestion(id: string) {
localQuestions.value = localQuestions.value.filter(q => q.id !== id)
message.success('已删除')
}
function clearAllLocalQuestions() {
localQuestions.value = []
message.success('已清空所有本地提问记录')
}
// 生命周期钩子
onMounted(() => {
getPublicQuestions()
@@ -379,12 +455,53 @@ onUnmounted(() => {
</script>
<template>
<div class="question-box-container">
<div class="question-box-wrapper">
<NDivider />
<div class="question-box-container">
<!-- 提问按钮 -->
<transition
name="fade-slide-down"
appear
>
<div class="question-toggle-section">
<NSpace :size="16" justify="center">
<NButton
type="primary"
size="large"
class="question-toggle-button"
:disabled="isSelf"
@click="isQuestionFormExpanded = !isQuestionFormExpanded"
>
<template #icon>
<NIcon :component="isQuestionFormExpanded ? DismissCircle24Regular : AddCircle24Regular" />
</template>
{{ isQuestionFormExpanded ? '收起' : '我要提问' }}
</NButton>
<!-- 未登录用户显示本地历史按钮 -->
<template v-if="!isUserLoggedIn">
<NBadge :value="localQuestions.length" :max="99" :show="localQuestions.length > 0">
<NButton
type="info"
size="large"
class="local-history-button"
@click="showLocalQuestionsDrawer = true"
>
<template #icon>
<NIcon :component="History24Regular" />
</template>
本地记录
</NButton>
</NBadge>
</template>
</NSpace>
</div>
</transition>
<!-- 提问表单 - 使用折叠动画 -->
<transition name="question-form">
<NCard
v-show="isQuestionFormExpanded"
embedded
class="question-form-card"
:class="{ 'self-user': isSelf }"
@@ -435,6 +552,61 @@ onUnmounted(() => {
/>
</div>
<!-- 匿名用户信息输入 -->
<transition
name="fade"
appear
>
<div
v-if="!isUserLoggedIn && !isSelf"
class="anonymous-info-section"
>
<NCard
size="small"
class="anonymous-info-card"
title="可选信息"
>
<NSpace
vertical
:size="12"
>
<div class="info-field">
<NText depth="3" class="field-label">
昵称可选
</NText>
<NInput
v-model:value="anonymousName"
placeholder="可以留下你的昵称"
maxlength="20"
show-count
clearable
/>
</div>
<div class="info-field">
<NText depth="3" class="field-label">
邮箱可选
</NText>
<NInput
v-model:value="anonymousEmail"
placeholder="主播回答后会收到邮件通知"
maxlength="100"
clearable
:status="anonymousEmail && !isValidEmail(anonymousEmail) ? 'error' : undefined"
/>
<NText
v-if="anonymousEmail && !isValidEmail(anonymousEmail)"
type="error"
depth="3"
style="font-size: 12px; margin-top: 4px;"
>
邮箱格式不正确
</NText>
</div>
</NSpace>
</NCard>
</div>
</transition>
<!-- 图片上传区域 - 重构为更简洁的条件块 -->
<div class="image-upload-container">
<!-- 已登录用户图片上传 -->
@@ -803,6 +975,105 @@ onUnmounted(() => {
</transition>
<NDivider />
<!-- 本地提问历史抽屉 -->
<NDrawer
v-model:show="showLocalQuestionsDrawer"
:width="500"
placement="right"
>
<NDrawerContent closable>
<template #header>
<NSpace justify="space-between" align="center" style="width: 100%;">
<NText strong style="font-size: 16px;">本地提问记录</NText>
<NButton
v-if="localQuestions.length > 0"
text
type="error"
size="small"
@click="clearAllLocalQuestions"
>
清空全部
</NButton>
</NSpace>
</template>
<div v-if="localQuestions.length === 0" class="empty-local-questions">
<NEmpty description="还没有本地提问记录" />
<NText depth="3" style="text-align: center; display: block; margin-top: 12px;">
未登录状态下发送的提问会保存到这里
</NText>
</div>
<NList v-else>
<NListItem
v-for="item in localQuestions"
:key="item.id"
class="local-question-item"
>
<NCard
size="small"
class="local-question-card"
hoverable
>
<template #header>
<NSpace :size="8" align="center" justify="space-between">
<NSpace :size="8" align="center">
<NText strong>提给{{ item.targetUserName }}</NText>
<NTag v-if="item.tag" size="small" type="info">
{{ item.tag }}
</NTag>
</NSpace>
<NButton
text
type="error"
size="small"
@click="deleteLocalQuestion(item.id)"
>
<template #icon>
<NIcon :component="DismissCircle24Regular" />
</template>
</NButton>
</NSpace>
</template>
<NSpace vertical :size="8">
<div class="local-question-message">
{{ item.message }}
</div>
<NDivider style="margin: 8px 0;" />
<NSpace :size="4" vertical>
<NText depth="3" style="font-size: 12px;">
<NTime :time="item.sendAt" format="yyyy-MM-dd HH:mm:ss" />
</NText>
<NText v-if="item.anonymousName" depth="3" style="font-size: 12px;">
昵称{{ item.anonymousName }}
</NText>
<NText v-if="item.anonymousEmail" depth="3" style="font-size: 12px;">
邮箱{{ item.anonymousEmail }}
</NText>
<NTag v-if="item.hasImage" size="tiny" type="success">
包含图片
</NTag>
</NSpace>
</NSpace>
</NCard>
</NListItem>
</NList>
<template #footer>
<NAlert type="info" size="small">
<template #icon>
<NIcon :component="History24Regular" />
</template>
本地记录仅保存在浏览器清除浏览器数据后将丢失
</NAlert>
</template>
</NDrawerContent>
</NDrawer>
</div>
</div>
</template>
@@ -810,6 +1081,12 @@ onUnmounted(() => {
.n-list {
background-color: transparent;
}
/* 包裹器样式 */
.question-box-wrapper {
width: 100%;
}
/* 基础容器样式 */
.question-box-container {
max-width: 700px;
@@ -820,6 +1097,43 @@ onUnmounted(() => {
box-sizing: border-box;
}
/* 提问按钮区域样式 */
.question-toggle-section {
margin-bottom: 16px;
display: flex;
justify-content: center;
}
.question-toggle-button,
.local-history-button {
min-width: 160px;
height: 48px;
font-size: 16px;
border-radius: 24px;
transition: all 0.3s ease;
}
.question-toggle-button {
box-shadow: 0 4px 12px rgba(24, 160, 88, 0.3);
}
.local-history-button {
box-shadow: 0 4px 12px rgba(42, 148, 229, 0.3);
}
.question-toggle-button:not(:disabled):hover,
.local-history-button:not(:disabled):hover {
transform: translateY(-2px);
}
.question-toggle-button:not(:disabled):hover {
box-shadow: 0 6px 16px rgba(24, 160, 88, 0.4);
}
.local-history-button:not(:disabled):hover {
box-shadow: 0 6px 16px rgba(42, 148, 229, 0.4);
}
/* 表单卡片样式 */
.question-form-card {
border-radius: 12px;
@@ -876,6 +1190,27 @@ onUnmounted(() => {
transition: all 0.3s ease;
}
/* 匿名用户信息输入区域 */
.anonymous-info-section {
margin: 16px 0;
}
.anonymous-info-card {
border-radius: 8px;
border: 1px solid var(--border-color);
}
.info-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 13px;
font-weight: 500;
}
/* 图片上传区域 */
.image-upload-container {
width: 100%;
@@ -934,6 +1269,22 @@ onUnmounted(() => {
cursor: pointer;
}
.image-preview-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.upload-preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.add-icon {
font-size: 24px;
color: var(--text-color);
@@ -1201,6 +1552,41 @@ onUnmounted(() => {
transform: scale(0.9);
}
/* 问题表单折叠动画 */
.question-form-enter-active {
animation: expand 0.4s ease-out;
}
.question-form-leave-active {
animation: collapse 0.3s ease-in;
}
@keyframes expand {
from {
opacity: 0;
max-height: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
max-height: 2000px;
transform: translateY(0);
}
}
@keyframes collapse {
from {
opacity: 1;
max-height: 2000px;
transform: translateY(0);
}
to {
opacity: 0;
max-height: 0;
transform: translateY(-20px);
}
}
.tag-list-move {
transition: all 0.5s ease;
}
@@ -1230,4 +1616,30 @@ onUnmounted(() => {
opacity: 0;
transform: translateY(30px);
}
/* 本地提问历史样式 */
.empty-local-questions {
padding: 40px 20px;
}
.local-question-item {
margin-bottom: 12px;
}
.local-question-card {
border-radius: 8px;
transition: all 0.3s ease;
}
.local-question-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.local-question-message {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
padding: 8px 0;
}
</style>