mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46: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
|
tag?: string
|
||||||
reviewResult?: QAReviewInfo
|
reviewResult?: QAReviewInfo
|
||||||
|
anonymousName?: string
|
||||||
|
anonymousEmail?: string
|
||||||
}
|
}
|
||||||
export interface LotteryUserInfo {
|
export interface LotteryUserInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -829,7 +831,7 @@ export interface ResponsePointHisrotyModel {
|
|||||||
createAt: number
|
createAt: number
|
||||||
count: number
|
count: number
|
||||||
|
|
||||||
extra?: any
|
extra?: any // Use 时包含: { user, goods, isDiscontinued, remark }; Manual 时包含: { user, reason }; Danmaku 时包含: { user, danmaku }; CheckIn 时包含: { user }
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PointFrom {
|
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']
|
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||||
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||||
NAlert: typeof import('naive-ui')['NAlert']
|
NAlert: typeof import('naive-ui')['NAlert']
|
||||||
|
NBadge: typeof import('naive-ui')['NBadge']
|
||||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
NFlex: typeof import('naive-ui')['NFlex']
|
NFlex: typeof import('naive-ui')['NFlex']
|
||||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||||
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
NTag: typeof import('naive-ui')['NTag']
|
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']
|
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
||||||
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
|
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
|
||||||
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
|
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
|
|||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
key: 'createAt',
|
key: 'createAt',
|
||||||
sorter: 'default',
|
sorter: (row1: ResponsePointHisrotyModel, row2: ResponsePointHisrotyModel) => row1.createAt - row2.createAt,
|
||||||
render: (row: ResponsePointHisrotyModel) => {
|
render: (row: ResponsePointHisrotyModel) => {
|
||||||
return h(NTooltip, null, {
|
return h(NTooltip, null, {
|
||||||
trigger: () => h(NTime, { time: row.createAt, type: 'relative' }),
|
trigger: () => h(NTime, { time: row.createAt, type: 'relative' }),
|
||||||
@@ -44,11 +44,13 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
|
|||||||
{
|
{
|
||||||
title: '积分变动',
|
title: '积分变动',
|
||||||
key: 'point',
|
key: 'point',
|
||||||
|
sorter: (row1: ResponsePointHisrotyModel, row2: ResponsePointHisrotyModel) => row1.point - row2.point,
|
||||||
render: (row: ResponsePointHisrotyModel) => {
|
render: (row: ResponsePointHisrotyModel) => {
|
||||||
|
const point = Number(row.point.toFixed(1))
|
||||||
return h(
|
return h(
|
||||||
NText,
|
NText,
|
||||||
{ style: { color: row.point < 0 ? 'red' : 'green' } },
|
{ style: { color: point < 0 ? 'red' : 'green' } },
|
||||||
() => (row.point < 0 ? '' : '+') + row.point,
|
() => (point < 0 ? '' : '+') + point,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -205,18 +207,29 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
|
|||||||
case PointFrom.Use:
|
case PointFrom.Use:
|
||||||
return h(NFlex, { align: 'center' }, () => [
|
return h(NFlex, { align: 'center' }, () => [
|
||||||
h(NTag, { type: 'success', size: 'small', style: { margin: '0' }, strong: true }, () => '兑换'),
|
h(NTag, { type: 'success', size: 'small', style: { margin: '0' }, strong: true }, () => '兑换'),
|
||||||
h(
|
row.extra?.goods
|
||||||
NButton,
|
? h(
|
||||||
{
|
NButton,
|
||||||
text: true,
|
{
|
||||||
type: 'info',
|
text: true,
|
||||||
onClick: () => {
|
type: 'info',
|
||||||
currentGoods.value = row.extra
|
onClick: () => {
|
||||||
showGoodsModal.value = true
|
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
|
return null
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ const orderColumn: DataTableColumns<OrderType> = [
|
|||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
key: 'time',
|
key: 'time',
|
||||||
sorter: 'default',
|
sorter: (row1: OrderType, row2: OrderType) => row1.createAt - row2.createAt,
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
render: (row: OrderType) => {
|
render: (row: OrderType) => {
|
||||||
return h(NTooltip, null, {
|
return h(NTooltip, null, {
|
||||||
@@ -196,7 +196,10 @@ const orderColumn: DataTableColumns<OrderType> = [
|
|||||||
{
|
{
|
||||||
title: '使用积分',
|
title: '使用积分',
|
||||||
key: 'point',
|
key: 'point',
|
||||||
sorter: 'default',
|
sorter: (row1: OrderType, row2: OrderType) => row1.point - row2.point,
|
||||||
|
render: (row: OrderType) => {
|
||||||
|
return Number(row.point.toFixed(1))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '订单状态',
|
title: '订单状态',
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const orderStats = computed(() => {
|
|||||||
completed: orders.value.filter(o => o.status === PointOrderStatus.Completed).length,
|
completed: orders.value.filter(o => o.status === PointOrderStatus.Completed).length,
|
||||||
physical: orders.value.filter(o => o.type === GoodsTypes.Physical).length,
|
physical: orders.value.filter(o => o.type === GoodsTypes.Physical).length,
|
||||||
virtual: orders.value.filter(o => o.type === GoodsTypes.Virtual).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,
|
filteredCount: filteredOrders.value.length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -200,8 +200,8 @@ function exportData() {
|
|||||||
: '无',
|
: '无',
|
||||||
礼物名: gift?.name ?? '已删除',
|
礼物名: gift?.name ?? '已删除',
|
||||||
礼物数量: s.count,
|
礼物数量: s.count,
|
||||||
礼物单价: gift?.price,
|
礼物单价: gift?.price ? Number(gift.price.toFixed(1)) : 0,
|
||||||
礼物总价: s.point,
|
礼物总价: Number(s.point.toFixed(1)),
|
||||||
快递公司: s.expressCompany,
|
快递公司: s.expressCompany,
|
||||||
快递单号: s.trackingNumber,
|
快递单号: s.trackingNumber,
|
||||||
备注: s.remark ?? '',
|
备注: s.remark ?? '',
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ async function givePoint() {
|
|||||||
if (data.code == 200) {
|
if (data.code == 200) {
|
||||||
message.success('添加成功')
|
message.success('添加成功')
|
||||||
showAddPointModal.value = false
|
showAddPointModal.value = false
|
||||||
props.user.point += addPointCount.value
|
props.user.point = Number((props.user.point + addPointCount.value).toFixed(1))
|
||||||
|
|
||||||
// 重新加载积分历史
|
// 重新加载积分历史
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
@@ -227,7 +227,7 @@ onMounted(async () => {
|
|||||||
</NDescriptionsItem>
|
</NDescriptionsItem>
|
||||||
|
|
||||||
<NDescriptionsItem label="积分">
|
<NDescriptionsItem label="积分">
|
||||||
{{ user.point }}
|
{{ Number(user.point.toFixed(1)) }}
|
||||||
</NDescriptionsItem>
|
</NDescriptionsItem>
|
||||||
|
|
||||||
<NDescriptionsItem
|
<NDescriptionsItem
|
||||||
|
|||||||
@@ -119,12 +119,14 @@ const filteredUsers = computed(() => {
|
|||||||
|
|
||||||
// 用户统计
|
// 用户统计
|
||||||
const userStats = 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 {
|
return {
|
||||||
total: users.value.length,
|
total: users.value.length,
|
||||||
authed: users.value.filter(u => u.isAuthed).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),
|
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,
|
filtered: filteredUsers.value.length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -197,9 +199,10 @@ function formatNumber(num: number) {
|
|||||||
return num.toLocaleString('zh-CN')
|
return num.toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染积分,添加千位符并加粗
|
// 渲染积分,添加千位符并加粗,保留一位小数
|
||||||
function renderPoint(num: number) {
|
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: '积分',
|
title: '积分',
|
||||||
key: 'point',
|
key: 'point',
|
||||||
sorter: 'default',
|
sorter: (row1: ResponsePointUserModel, row2: ResponsePointUserModel) => row1.point - row2.point,
|
||||||
render: (row: ResponsePointUserModel) => renderPoint(row.point),
|
render: (row: ResponsePointUserModel) => renderPoint(row.point),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -230,7 +233,7 @@ const column: DataTableColumns<ResponsePointUserModel> = [
|
|||||||
{
|
{
|
||||||
title: '最后更新于',
|
title: '最后更新于',
|
||||||
key: 'updateAt',
|
key: 'updateAt',
|
||||||
sorter: 'default',
|
sorter: (row1: ResponsePointUserModel, row2: ResponsePointUserModel) => row1.updateAt - row2.updateAt,
|
||||||
render: (row: ResponsePointUserModel) => renderTime(row.updateAt),
|
render: (row: ResponsePointUserModel) => renderTime(row.updateAt),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -374,7 +377,7 @@ function exportData() {
|
|||||||
用户ID: user.info.userId || user.info.openId,
|
用户ID: user.info.userId || user.info.openId,
|
||||||
用户名: user.info.name || '未知',
|
用户名: user.info.name || '未知',
|
||||||
认证状态: user.isAuthed ? '已认证' : '未认证',
|
认证状态: user.isAuthed ? '已认证' : '未认证',
|
||||||
积分: user.point,
|
积分: Number(user.point.toFixed(1)),
|
||||||
订单数量: user.orderCount || 0,
|
订单数量: user.orderCount || 0,
|
||||||
最后更新时间: format(user.updateAt, 'yyyy-MM-dd HH:mm:ss'),
|
最后更新时间: 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(() => {
|
const addressOptions = computed(() => {
|
||||||
if (!biliAuth.value.id) return []
|
if (!biliAuth.value.id) return []
|
||||||
@@ -264,7 +270,7 @@ async function buyGoods() {
|
|||||||
// 确认对话框
|
// 确认对话框
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: '确认兑换',
|
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: '确定',
|
positiveText: '确定',
|
||||||
negativeText: '取消',
|
negativeText: '取消',
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
@@ -281,7 +287,7 @@ async function buyGoods() {
|
|||||||
if (data.code === 200) {
|
if (data.code === 200) {
|
||||||
message.success('兑换成功')
|
message.success('兑换成功')
|
||||||
// 更新本地积分显示
|
// 更新本地积分显示
|
||||||
currentPoint.value -= currentGoods.value!.price * buyCount.value
|
currentPoint.value = Number((currentPoint.value - currentGoods.value!.price * buyCount.value).toFixed(1))
|
||||||
// 显示成功对话框
|
// 显示成功对话框
|
||||||
dialog.success({
|
dialog.success({
|
||||||
title: '成功',
|
title: '成功',
|
||||||
@@ -426,7 +432,7 @@ onMounted(async () => {
|
|||||||
v-if="currentPoint >= 0"
|
v-if="currentPoint >= 0"
|
||||||
class="point-info"
|
class="point-info"
|
||||||
>
|
>
|
||||||
你在本直播间的积分: <strong>{{ currentPoint }}</strong>
|
你在本直播间的积分: <strong>{{ formattedCurrentPoint }}</strong>
|
||||||
</NText>
|
</NText>
|
||||||
<NText
|
<NText
|
||||||
v-else
|
v-else
|
||||||
@@ -802,9 +808,9 @@ onMounted(async () => {
|
|||||||
确认兑换
|
确认兑换
|
||||||
</NButton>
|
</NButton>
|
||||||
<NText depth="2">
|
<NText depth="2">
|
||||||
所需积分: {{ currentGoods.price * buyCount }}
|
所需积分: {{ Number((currentGoods.price * buyCount).toFixed(1)) }}
|
||||||
<NDivider vertical />
|
<NDivider vertical />
|
||||||
当前积分: {{ currentPoint >= 0 ? currentPoint : '加载中' }}
|
当前积分: {{ currentPoint >= 0 ? formattedCurrentPoint : '加载中' }}
|
||||||
</NText>
|
</NText>
|
||||||
</NFlex>
|
</NFlex>
|
||||||
</NModal>
|
</NModal>
|
||||||
|
|||||||
@@ -42,12 +42,13 @@ const filteredOrders = computed(() => {
|
|||||||
|
|
||||||
// 订单统计
|
// 订单统计
|
||||||
const orderStats = computed(() => {
|
const orderStats = computed(() => {
|
||||||
|
const totalPoints = orders.value.reduce((sum, o) => sum + o.point, 0)
|
||||||
return {
|
return {
|
||||||
total: orders.value.length,
|
total: orders.value.length,
|
||||||
pending: orders.value.filter(o => o.status === PointOrderStatus.Pending).length,
|
pending: orders.value.filter(o => o.status === PointOrderStatus.Pending).length,
|
||||||
shipped: orders.value.filter(o => o.status === PointOrderStatus.Shipped).length,
|
shipped: orders.value.filter(o => o.status === PointOrderStatus.Shipped).length,
|
||||||
completed: orders.value.filter(o => o.status === PointOrderStatus.Completed).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 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 {
|
return {
|
||||||
total: history.value.length,
|
total: history.value.length,
|
||||||
totalIncrease: history.value.filter(h => h.point > 0).reduce((sum, h) => sum + h.point, 0),
|
totalIncrease: Number(totalIncrease.toFixed(1)),
|
||||||
totalDecrease: Math.abs(history.value.filter(h => h.point < 0).reduce((sum, h) => sum + h.point, 0)),
|
totalDecrease: Number(totalDecrease.toFixed(1)),
|
||||||
|
netIncrease: Number((totalIncrease - totalDecrease).toFixed(1)),
|
||||||
fromManual: history.value.filter(h => h.from === PointFrom.Manual).length,
|
fromManual: history.value.filter(h => h.from === PointFrom.Manual).length,
|
||||||
fromDanmaku: history.value.filter(h => h.from === PointFrom.Danmaku).length,
|
fromDanmaku: history.value.filter(h => h.from === PointFrom.Danmaku).length,
|
||||||
fromCheckIn: history.value.filter(h => h.from === PointFrom.CheckIn).length,
|
fromCheckIn: history.value.filter(h => h.from === PointFrom.CheckIn).length,
|
||||||
@@ -140,7 +143,7 @@ function exportHistoryData() {
|
|||||||
filteredHistory.value.map((item) => {
|
filteredHistory.value.map((item) => {
|
||||||
return {
|
return {
|
||||||
时间: format(item.createAt, 'yyyy-MM-dd HH:mm:ss'),
|
时间: format(item.createAt, 'yyyy-MM-dd HH:mm:ss'),
|
||||||
积分变化: item.point,
|
积分变化: Number(item.point.toFixed(1)),
|
||||||
来源: pointFromText[item.from] || '未知',
|
来源: pointFromText[item.from] || '未知',
|
||||||
主播: item.extra?.user?.name || '-',
|
主播: item.extra?.user?.name || '-',
|
||||||
数量: item.count || '-',
|
数量: item.count || '-',
|
||||||
@@ -206,7 +209,7 @@ function exportHistoryData() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-value primary">
|
<div class="stat-value primary">
|
||||||
{{ historyStats.totalIncrease - historyStats.totalDecrease }}
|
{{ historyStats.netIncrease }}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-label">
|
<div class="stat-label">
|
||||||
净增加
|
净增加
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ const isLoading = ref(false)
|
|||||||
const userAgree = ref(false)
|
const userAgree = ref(false)
|
||||||
const showAddressModal = ref(false)
|
const showAddressModal = ref(false)
|
||||||
const showAgreementModal = ref(false)
|
const showAgreementModal = ref(false)
|
||||||
|
const showAddAccountModal = ref(false)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const currentAddress = ref<AddressInfo>()
|
const currentAddress = ref<AddressInfo>()
|
||||||
|
const authCodeInput = ref('')
|
||||||
|
|
||||||
// 本地存储区域数据
|
// 本地存储区域数据
|
||||||
const areas = useStorage<{
|
const areas = useStorage<{
|
||||||
@@ -255,11 +257,53 @@ function logout() {
|
|||||||
useAuth.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() {
|
function reset() {
|
||||||
// 重置表单数据或其他状态
|
// 重置表单数据或其他状态
|
||||||
currentAddress.value = {} as AddressInfo
|
currentAddress.value = {} as AddressInfo
|
||||||
userAgree.value = false
|
userAgree.value = false
|
||||||
|
authCodeInput.value = ''
|
||||||
// 可能还需要重置其他状态
|
// 可能还需要重置其他状态
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,17 +419,26 @@ defineExpose({
|
|||||||
vertical
|
vertical
|
||||||
:gap="12"
|
:gap="12"
|
||||||
>
|
>
|
||||||
<NPopconfirm @positive-click="logout">
|
<NFlex :gap="8">
|
||||||
<template #trigger>
|
<NPopconfirm @positive-click="logout">
|
||||||
<NButton
|
<template #trigger>
|
||||||
type="warning"
|
<NButton
|
||||||
size="small"
|
type="warning"
|
||||||
>
|
size="small"
|
||||||
登出当前账号
|
>
|
||||||
</NButton>
|
登出当前账号
|
||||||
</template>
|
</NButton>
|
||||||
确定要登出吗?
|
</template>
|
||||||
</NPopconfirm>
|
确定要登出吗?
|
||||||
|
</NPopconfirm>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="showAddAccountModal = true"
|
||||||
|
>
|
||||||
|
添加账号
|
||||||
|
</NButton>
|
||||||
|
</NFlex>
|
||||||
<NDivider style="margin: 8px 0">
|
<NDivider style="margin: 8px 0">
|
||||||
切换账号
|
切换账号
|
||||||
</NDivider>
|
</NDivider>
|
||||||
@@ -419,7 +472,7 @@ defineExpose({
|
|||||||
type="success"
|
type="success"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
当前账号
|
当前
|
||||||
</NTag>
|
</NTag>
|
||||||
<NText strong>
|
<NText strong>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
@@ -587,6 +640,46 @@ defineExpose({
|
|||||||
<UserAgreement />
|
<UserAgreement />
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
</NModal>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -608,7 +701,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current-account {
|
.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">
|
<script setup lang="ts">
|
||||||
import type { QAInfo, Setting_QuestionBox, UserInfo } from '@/api/api-models'
|
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 GraphemeSplitter from 'grapheme-splitter'
|
||||||
import {
|
import {
|
||||||
NAlert,
|
NAlert,
|
||||||
NAvatar,
|
NAvatar,
|
||||||
|
NBadge,
|
||||||
NButton,
|
NButton,
|
||||||
NCard,
|
NCard,
|
||||||
NCheckbox,
|
NCheckbox,
|
||||||
|
NCollapse,
|
||||||
|
NCollapseItem,
|
||||||
NDivider,
|
NDivider,
|
||||||
|
NDrawer,
|
||||||
|
NDrawerContent,
|
||||||
NEmpty,
|
NEmpty,
|
||||||
NIcon,
|
NIcon,
|
||||||
NImage,
|
NImage,
|
||||||
@@ -25,6 +30,7 @@ import {
|
|||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
import VueTurnstile from 'vue-turnstile'
|
import VueTurnstile from 'vue-turnstile'
|
||||||
import { useAccount } from '@/api/account'
|
import { useAccount } from '@/api/account'
|
||||||
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
|
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
|
||||||
@@ -36,11 +42,34 @@ const { biliInfo, userInfo } = defineProps<{
|
|||||||
userInfo: UserInfo | undefined
|
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 message = useMessage()
|
||||||
const accountInfo = useAccount()
|
const accountInfo = useAccount()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const splitter = new GraphemeSplitter()
|
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('')
|
const questionMessage = ref('')
|
||||||
@@ -54,6 +83,8 @@ const token = ref('')
|
|||||||
const turnstile = ref()
|
const turnstile = ref()
|
||||||
const nextSendQuestionTime = ref(Date.now())
|
const nextSendQuestionTime = ref(Date.now())
|
||||||
const minSendQuestionTime = 30 * 1000
|
const minSendQuestionTime = 30 * 1000
|
||||||
|
const anonymousName = ref('')
|
||||||
|
const anonymousEmail = ref('')
|
||||||
|
|
||||||
// 图片上传相关状态
|
// 图片上传相关状态
|
||||||
const targetUserSetting = ref<Setting_QuestionBox | null>(null)
|
const targetUserSetting = ref<Setting_QuestionBox | null>(null)
|
||||||
@@ -76,6 +107,12 @@ function countGraphemes(value: string) {
|
|||||||
return splitter.countGraphemes(value)
|
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 } {
|
function validateImageFile(file: File): { valid: boolean, message?: string } {
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
@@ -234,6 +271,12 @@ async function SendQuestion() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证邮箱格式
|
||||||
|
if (anonymousEmail.value && !isValidEmail(anonymousEmail.value)) {
|
||||||
|
message.error('邮箱格式不正确')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isSending.value = true
|
isSending.value = true
|
||||||
let uploadedFileIds: number[] = []
|
let uploadedFileIds: number[] = []
|
||||||
let imagePayload: { id: number }[] | undefined
|
let imagePayload: { id: number }[] | undefined
|
||||||
@@ -278,6 +321,8 @@ async function SendQuestion() {
|
|||||||
Tag: selectedTag.value,
|
Tag: selectedTag.value,
|
||||||
Images: imagePayload,
|
Images: imagePayload,
|
||||||
ImageTokens: tokenPayload ? [tokenPayload] : undefined,
|
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>(
|
const data = await QueryPostAPI<QAInfo>(
|
||||||
@@ -288,11 +333,31 @@ async function SendQuestion() {
|
|||||||
|
|
||||||
if (data.code == 200) {
|
if (data.code == 200) {
|
||||||
message.success('成功发送棉花糖')
|
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 = ''
|
questionMessage.value = ''
|
||||||
|
anonymousName.value = ''
|
||||||
|
anonymousEmail.value = ''
|
||||||
removeAnonymousImage()
|
removeAnonymousImage()
|
||||||
clearAllLoggedInImages()
|
clearAllLoggedInImages()
|
||||||
nextSendQuestionTime.value = Date.now() + minSendQuestionTime
|
nextSendQuestionTime.value = Date.now() + minSendQuestionTime
|
||||||
getPublicQuestions()
|
getPublicQuestions()
|
||||||
|
isQuestionFormExpanded.value = false
|
||||||
} else {
|
} else {
|
||||||
message.error(data.message || '发送失败')
|
message.error(data.message || '发送失败')
|
||||||
if (tokenPayload && (data.message.includes('token') || data.code === 400)) {
|
if (tokenPayload && (data.message.includes('token') || data.code === 400)) {
|
||||||
@@ -365,6 +430,17 @@ function onSelectTag(tag: string) {
|
|||||||
selectedTag.value = selectedTag.value === tag ? null : tag
|
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(() => {
|
onMounted(() => {
|
||||||
getPublicQuestions()
|
getPublicQuestions()
|
||||||
@@ -379,12 +455,53 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="question-box-container">
|
<div class="question-box-wrapper">
|
||||||
|
<NDivider />
|
||||||
|
<div class="question-box-container">
|
||||||
|
<!-- 提问按钮 -->
|
||||||
<transition
|
<transition
|
||||||
name="fade-slide-down"
|
name="fade-slide-down"
|
||||||
appear
|
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
|
<NCard
|
||||||
|
v-show="isQuestionFormExpanded"
|
||||||
embedded
|
embedded
|
||||||
class="question-form-card"
|
class="question-form-card"
|
||||||
:class="{ 'self-user': isSelf }"
|
:class="{ 'self-user': isSelf }"
|
||||||
@@ -435,6 +552,61 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="image-upload-container">
|
||||||
<!-- 已登录用户图片上传 -->
|
<!-- 已登录用户图片上传 -->
|
||||||
@@ -803,6 +975,105 @@ onUnmounted(() => {
|
|||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<NDivider />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -810,6 +1081,12 @@ onUnmounted(() => {
|
|||||||
.n-list {
|
.n-list {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 包裹器样式 */
|
||||||
|
.question-box-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* 基础容器样式 */
|
/* 基础容器样式 */
|
||||||
.question-box-container {
|
.question-box-container {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
@@ -820,6 +1097,43 @@ onUnmounted(() => {
|
|||||||
box-sizing: border-box;
|
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 {
|
.question-form-card {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -876,6 +1190,27 @@ onUnmounted(() => {
|
|||||||
transition: all 0.3s ease;
|
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 {
|
.image-upload-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -934,6 +1269,22 @@ onUnmounted(() => {
|
|||||||
cursor: pointer;
|
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 {
|
.add-icon {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -1201,6 +1552,41 @@ onUnmounted(() => {
|
|||||||
transform: scale(0.9);
|
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 {
|
.tag-list-move {
|
||||||
transition: all 0.5s ease;
|
transition: all 0.5s ease;
|
||||||
}
|
}
|
||||||
@@ -1230,4 +1616,30 @@ onUnmounted(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(30px);
|
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>
|
</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