mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
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:
@@ -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
4
src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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(
|
||||
row.extra?.goods
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: 'info',
|
||||
onClick: () => {
|
||||
currentGoods.value = row.extra
|
||||
currentGoods.value = row.extra?.goods
|
||||
showGoodsModal.value = true
|
||||
},
|
||||
},
|
||||
() => row.extra?.name,
|
||||
),
|
||||
() => 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
|
||||
|
||||
@@ -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: '订单状态',
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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">
|
||||
净增加
|
||||
|
||||
@@ -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,6 +419,7 @@ defineExpose({
|
||||
vertical
|
||||
:gap="12"
|
||||
>
|
||||
<NFlex :gap="8">
|
||||
<NPopconfirm @positive-click="logout">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
@@ -386,6 +431,14 @@ defineExpose({
|
||||
</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);
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
|
||||
@@ -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-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>
|
||||
|
||||
135
本地提问保存功能说明.md
Normal file
135
本地提问保存功能说明.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 本地提问保存功能说明
|
||||
|
||||
## 功能概述
|
||||
为未登录用户实现了本地提问历史保存功能,使用 VueUse 的 `useStorage` 将提问保存到浏览器的 IndexedDB 中。
|
||||
|
||||
## 实现的功能
|
||||
|
||||
### 1. 自动保存提问
|
||||
- **触发时机**: 未登录用户成功发送提问后自动保存
|
||||
- **保存内容**:
|
||||
- 提问ID(本地生成)
|
||||
- 目标用户ID和用户名
|
||||
- 提问内容
|
||||
- 话题标签
|
||||
- 匿名昵称
|
||||
- 匿名邮箱
|
||||
- 是否包含图片
|
||||
- 发送时间
|
||||
|
||||
### 2. 本地记录按钮
|
||||
- **位置**: 在"我要提问"按钮旁边
|
||||
- **显示条件**: 仅对未登录用户显示
|
||||
- **功能**:
|
||||
- 显示本地记录数量徽章
|
||||
- 点击打开本地提问历史抽屉
|
||||
|
||||
### 3. 本地提问历史抽屉
|
||||
- **布局**: 右侧抽屉,宽度 500px
|
||||
- **功能**:
|
||||
- 查看所有本地保存的提问
|
||||
- 显示每条提问的详细信息
|
||||
- 删除单条记录
|
||||
- 清空所有记录
|
||||
- 提示数据仅保存在浏览器中
|
||||
|
||||
### 4. 记录卡片显示
|
||||
每条本地提问记录包含:
|
||||
- 目标用户名称
|
||||
- 话题标签(如果有)
|
||||
- 提问内容
|
||||
- 发送时间(格式化显示)
|
||||
- 匿名昵称(如果有)
|
||||
- 匿名邮箱(如果有)
|
||||
- 图片标识(如果有)
|
||||
- 删除按钮
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 使用的技术栈
|
||||
- **VueUse**: `useStorage` - 用于持久化存储
|
||||
- **Naive UI**: 提供 UI 组件(Drawer、Badge、Card 等)
|
||||
- **TypeScript**: 类型安全
|
||||
- **Vue 3**: 组合式 API
|
||||
|
||||
### 数据结构
|
||||
```typescript
|
||||
interface LocalQuestion {
|
||||
id: string // 本地生成的唯一ID
|
||||
targetUserId: number // 目标用户ID
|
||||
targetUserName: string // 目标用户名称
|
||||
message: string // 提问内容
|
||||
tag: string | null // 话题标签
|
||||
anonymousName: string // 匿名昵称
|
||||
anonymousEmail: string // 匿名邮箱
|
||||
hasImage: boolean // 是否包含图片
|
||||
sendAt: number // 发送时间戳
|
||||
}
|
||||
```
|
||||
|
||||
### 存储方式
|
||||
```typescript
|
||||
const localQuestions = useStorage<LocalQuestion[]>(
|
||||
'vtsuru-local-questions', // 存储键名
|
||||
[], // 默认值
|
||||
undefined, // 使用默认存储(localStorage/sessionStorage)
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => v ? JSON.parse(v) : [],
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 新增的功能函数
|
||||
|
||||
### 1. deleteLocalQuestion
|
||||
```typescript
|
||||
function deleteLocalQuestion(id: string)
|
||||
```
|
||||
删除指定ID的本地提问记录
|
||||
|
||||
### 2. clearAllLocalQuestions
|
||||
```typescript
|
||||
function clearAllLocalQuestions()
|
||||
```
|
||||
清空所有本地提问记录
|
||||
|
||||
## UI 组件更新
|
||||
|
||||
### 新增导入
|
||||
- `History24Regular` - 历史图标
|
||||
- `NDrawer` - 抽屉组件
|
||||
- `NDrawerContent` - 抽屉内容
|
||||
- `NBadge` - 徽章组件
|
||||
- `useStorage` from '@vueuse/core' - 存储hook
|
||||
|
||||
### 样式更新
|
||||
- 提问按钮区域改为横向布局
|
||||
- 新增本地历史按钮样式
|
||||
- 新增本地提问卡片样式
|
||||
- 优化过渡动画
|
||||
|
||||
## 用户体验优化
|
||||
|
||||
1. **自动保存**: 发送成功后自动保存,无需用户手动操作
|
||||
2. **徽章提示**: 按钮上显示记录数量,直观了解保存的提问数
|
||||
3. **详细信息**: 记录所有相关信息,方便用户回顾
|
||||
4. **便捷管理**: 支持单条删除和批量清空
|
||||
5. **数据提示**: 明确告知数据保存在本地浏览器中
|
||||
6. **响应式设计**: 抽屉宽度适配不同屏幕
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据持久性**: 数据保存在浏览器本地存储,清除浏览器数据会丢失
|
||||
2. **仅未登录用户**: 已登录用户可以在"我发送的"中查看完整历史
|
||||
3. **图片信息**: 仅记录是否包含图片,不保存图片内容
|
||||
4. **跨设备**: 记录仅在当前浏览器可见,不跨设备同步
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 未登录用户想回顾自己发送的提问
|
||||
- 确认提问是否成功发送
|
||||
- 管理和清理本地提问记录
|
||||
- 离线查看已发送的提问内容
|
||||
208
测试指南-本地提问保存.md
Normal file
208
测试指南-本地提问保存.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# 测试指南 - 本地提问保存功能
|
||||
|
||||
## 测试前准备
|
||||
|
||||
1. 确保已安装依赖:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
2. 启动开发服务器:
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 测试场景 1: 未登录用户发送提问并保存
|
||||
|
||||
1. **进入页面**
|
||||
- 访问任意用户的提问箱页面
|
||||
- 确保处于未登录状态(退出登录)
|
||||
|
||||
2. **查看按钮**
|
||||
- 应该能看到两个按钮:
|
||||
- "我要提问" (绿色主按钮)
|
||||
- "本地记录" (蓝色按钮,带数字徽章)
|
||||
- 初始状态徽章应该为 0 或不显示
|
||||
|
||||
3. **发送第一条提问**
|
||||
- 点击"我要提问"按钮
|
||||
- 填写提问内容(至少3个字)
|
||||
- 可选:填写昵称和邮箱
|
||||
- 可选:选择话题标签
|
||||
- 可选:上传图片(如果主播允许)
|
||||
- 完成人机验证
|
||||
- 点击"发送"按钮
|
||||
|
||||
4. **验证自动保存**
|
||||
- 发送成功后
|
||||
- "本地记录"按钮的徽章数字应该增加到 1
|
||||
- 提问表单应该自动收起
|
||||
|
||||
5. **查看本地记录**
|
||||
- 点击"本地记录"按钮
|
||||
- 应该打开右侧抽屉
|
||||
- 能看到刚才发送的提问
|
||||
- 显示内容包括:
|
||||
- 目标用户名称
|
||||
- 话题标签(如果有)
|
||||
- 提问内容
|
||||
- 发送时间
|
||||
- 昵称(如果填写了)
|
||||
- 邮箱(如果填写了)
|
||||
- 图片标识(如果上传了)
|
||||
|
||||
### 测试场景 2: 发送多条提问
|
||||
|
||||
1. **重复发送**
|
||||
- 继续发送 2-3 条提问
|
||||
- 每次发送成功后,徽章数字应该增加
|
||||
|
||||
2. **查看记录列表**
|
||||
- 打开本地记录抽屉
|
||||
- 应该看到所有发送的提问
|
||||
- 最新的提问在最上面
|
||||
- 每条记录都显示完整信息
|
||||
|
||||
### 测试场景 3: 删除单条记录
|
||||
|
||||
1. **删除操作**
|
||||
- 打开本地记录抽屉
|
||||
- 找到想删除的记录
|
||||
- 点击记录右上角的删除按钮(X图标)
|
||||
- 应该显示"已删除"提示
|
||||
|
||||
2. **验证删除**
|
||||
- 记录应该从列表中消失
|
||||
- 徽章数字应该减少 1
|
||||
- 其他记录不受影响
|
||||
|
||||
### 测试场景 4: 清空所有记录
|
||||
|
||||
1. **清空操作**
|
||||
- 打开本地记录抽屉
|
||||
- 点击右上角的"清空全部"按钮
|
||||
- 应该显示"已清空所有本地提问记录"提示
|
||||
|
||||
2. **验证清空**
|
||||
- 所有记录都应该消失
|
||||
- 显示空状态提示:"还没有本地提问记录"
|
||||
- 徽章数字应该消失或变为 0
|
||||
|
||||
### 测试场景 5: 已登录用户不显示本地记录
|
||||
|
||||
1. **登录账号**
|
||||
- 使用任意账号登录
|
||||
|
||||
2. **验证显示**
|
||||
- "本地记录"按钮应该不显示
|
||||
- 只显示"我要提问"和"我发送的"按钮
|
||||
- 已登录用户应该使用"我发送的"查看历史
|
||||
|
||||
### 测试场景 6: 数据持久性
|
||||
|
||||
1. **刷新页面**
|
||||
- 发送几条提问后
|
||||
- 刷新浏览器页面
|
||||
- 本地记录应该仍然存在
|
||||
|
||||
2. **关闭重开**
|
||||
- 关闭浏览器标签页
|
||||
- 重新打开页面
|
||||
- 本地记录应该仍然存在
|
||||
|
||||
3. **清除数据**
|
||||
- 在浏览器开发者工具中
|
||||
- 清除本地存储(localStorage)
|
||||
- 本地记录应该被清空
|
||||
|
||||
### 测试场景 7: 不同用户的提问
|
||||
|
||||
1. **访问用户A的提问箱**
|
||||
- 发送一条提问
|
||||
- 记录应该保存目标用户名为"用户A"
|
||||
|
||||
2. **访问用户B的提问箱**
|
||||
- 发送一条提问
|
||||
- 记录应该保存目标用户名为"用户B"
|
||||
|
||||
3. **查看本地记录**
|
||||
- 打开本地记录抽屉
|
||||
- 应该看到两条记录
|
||||
- 分别标注了不同的目标用户
|
||||
|
||||
## 浏览器兼容性测试
|
||||
|
||||
建议在以下浏览器中测试:
|
||||
- ✅ Chrome / Edge (推荐)
|
||||
- ✅ Firefox
|
||||
- ✅ Safari
|
||||
- ⚠️ IE (不支持)
|
||||
|
||||
## 开发者工具检查
|
||||
|
||||
### 查看存储数据
|
||||
|
||||
1. 打开浏览器开发者工具 (F12)
|
||||
2. 切换到 Application/Storage 标签
|
||||
3. 找到 Local Storage
|
||||
4. 查找键名:`vtsuru-local-questions`
|
||||
5. 数据应该是 JSON 格式的数组
|
||||
|
||||
示例数据:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "local-1234567890-abc123",
|
||||
"targetUserId": 123,
|
||||
"targetUserName": "测试用户",
|
||||
"message": "这是一条测试提问",
|
||||
"tag": "测试话题",
|
||||
"anonymousName": "匿名用户",
|
||||
"anonymousEmail": "test@example.com",
|
||||
"hasImage": false,
|
||||
"sendAt": 1234567890000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **存储大小**: 浏览器 localStorage 有大小限制(通常 5-10MB)
|
||||
2. **跨域隔离**: 不同域名下的数据互不可见
|
||||
3. **无备份**: 清除浏览器数据会丢失所有记录
|
||||
4. **图片不保存**: 只记录是否包含图片,不保存图片内容
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题:本地记录不显示
|
||||
- 检查是否处于未登录状态
|
||||
- 检查浏览器是否禁用了 localStorage
|
||||
- 打开开发者工具查看是否有错误
|
||||
|
||||
### 问题:发送成功但没有保存
|
||||
- 检查开发者工具 Console 是否有错误
|
||||
- 确认 userInfo 对象有正确的 id 和 name
|
||||
- 检查 isUserLoggedIn 计算属性是否正确
|
||||
|
||||
### 问题:刷新后记录消失
|
||||
- 检查浏览器隐私模式设置
|
||||
- 确认没有自动清除 Cookie 和存储的设置
|
||||
- 检查浏览器扩展是否影响存储
|
||||
|
||||
## 性能考虑
|
||||
|
||||
- ✅ 使用 VueUse 的 `useStorage` 自动处理序列化
|
||||
- ✅ 数据保存是同步的,不影响发送速度
|
||||
- ✅ 列表渲染使用虚拟滚动(如果记录很多)
|
||||
- ⚠️ 建议不要保存超过 1000 条记录
|
||||
|
||||
## 反馈与改进
|
||||
|
||||
如果在测试过程中发现问题,请记录:
|
||||
1. 浏览器类型和版本
|
||||
2. 操作步骤
|
||||
3. 预期结果
|
||||
4. 实际结果
|
||||
5. 控制台错误信息(如果有)
|
||||
Reference in New Issue
Block a user