Compare commits

...

2 Commits

Author SHA1 Message Date
Megghy
4ad9766043 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.
2025-10-10 14:35:56 +08:00
Megghy
c9ec427692 feat: 更新 ESLint 配置以放宽不安全参数传递的限制;在 API 查询中添加 Bili-Auth 支持;优化路由参数传递逻辑 2025-10-10 13:25:07 +08:00
19 changed files with 961 additions and 64 deletions

View File

@@ -85,6 +85,7 @@ export default antfu(
'ts/restrict-template-expressions': 'off', // 允许模板字符串表达式不受限制
'perfectionist/sort-imports': 'off',
'ts/no-unsafe-argument': 'off', // 允许不安全的参数传递
// JSON 相关规则
'jsonc/sort-keys': 'off', // 关闭 JSON key 排序要求

View File

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

View File

@@ -1,6 +1,7 @@
import type { APIRoot, PaginationResponse } from './api-models'
import { apiFail } from '@/data/constants'
import { cookie } from './account'
import { useBiliAuth } from '@/store/useBiliAuth';
export async function QueryPostAPI<T>(
urlString: string,
@@ -57,6 +58,10 @@ async function QueryPostAPIWithParamsInternal<T>(
h[header[0]] = header[1]
})
if (cookie.value.cookie) h.Authorization = `Bearer ${cookie.value.cookie}`
const biliAuth = useBiliAuth();
if (biliAuth.currentToken) {
h['Bili-Auth'] = biliAuth.currentToken;
}
// 当使用FormData时不手动设置Content-Type让浏览器自动添加boundary
if (!(body instanceof FormData)) {
@@ -117,6 +122,10 @@ async function QueryGetAPIInternal<T>(
if (cookie.value.cookie) {
h.Authorization = `Bearer ${cookie.value.cookie}`
}
const biliAuth = useBiliAuth();
if (biliAuth.currentToken) {
h['Bili-Auth'] = biliAuth.currentToken;
}
return await QueryAPIInternal<T>(url, { method: 'get', headers: h })
} catch (err) {
console.log(`url:${urlString}, error:${err}`)

8
src/components.d.ts vendored
View File

@@ -19,20 +19,14 @@ declare module 'vue' {
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NDataTable: typeof import('naive-ui')['NDataTable']
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']
NImage: typeof import('naive-ui')['NImage']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTime: typeof import('naive-ui')['NTime']

View File

@@ -33,7 +33,7 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
{
title: '时间',
key: 'createAt',
sorter: 'default',
sorter: (row1: ResponsePointHisrotyModel, row2: ResponsePointHisrotyModel) => row1.createAt - row2.createAt,
render: (row: ResponsePointHisrotyModel) => {
return h(NTooltip, null, {
trigger: () => h(NTime, { time: row.createAt, type: 'relative' }),
@@ -44,11 +44,13 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
{
title: '积分变动',
key: 'point',
sorter: (row1: ResponsePointHisrotyModel, row2: ResponsePointHisrotyModel) => row1.point - row2.point,
render: (row: ResponsePointHisrotyModel) => {
const point = Number(row.point.toFixed(1))
return h(
NText,
{ style: { color: row.point < 0 ? 'red' : 'green' } },
() => (row.point < 0 ? '' : '+') + row.point,
{ style: { color: point < 0 ? 'red' : 'green' } },
() => (point < 0 ? '' : '+') + point,
)
},
},
@@ -205,18 +207,29 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
case PointFrom.Use:
return h(NFlex, { align: 'center' }, () => [
h(NTag, { type: 'success', size: 'small', style: { margin: '0' }, strong: true }, () => '兑换'),
h(
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

View File

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

View File

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

View File

@@ -110,7 +110,7 @@ export const useBiliAuth = defineStore('BiliAuth', () => {
return []
}
try {
const resp = await QueryGetAPI<ResponsePointGoodModel[]>(`${POINT_API_URL}get-goods`, {
const resp = await QueryBiliAuthGetAPI<ResponsePointGoodModel[]>(`${POINT_API_URL}get-goods`, {
id,
})
if (resp.code == 200) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,8 +49,10 @@ const isLoading = ref(false)
const userAgree = ref(false)
const showAddressModal = ref(false)
const showAgreementModal = ref(false)
const showAddAccountModal = ref(false)
const formRef = ref()
const currentAddress = ref<AddressInfo>()
const authCodeInput = ref('')
// 本地存储区域数据
const areas = useStorage<{
@@ -255,11 +257,53 @@ function logout() {
useAuth.logout()
}
// 添加账号
async function addAccount() {
if (!authCodeInput.value.trim()) {
message.warning('请输入登录链接或authcode')
return
}
isLoading.value = true
try {
let authCode = authCodeInput.value.trim()
// 检查是否是完整的登录链接
const urlMatch = authCode.match(/[?&]auth=([^&]+)/)
if (urlMatch) {
authCode = urlMatch[1]
}
// 验证authCode格式这里假设是token格式
if (!authCode) {
message.error('无效的authcode格式')
return
}
// 尝试使用authCode登录
await useAuth.setCurrentAuth(authCode)
// 检查是否登录成功
if (useAuth.isInvalid) {
message.error('无效的authcode请检查后重试')
} else {
message.success('账号添加成功')
showAddAccountModal.value = false
authCodeInput.value = ''
}
} catch (err) {
handleApiError('添加账号', err)
} finally {
isLoading.value = false
}
}
// 提供给父组件调用的重置方法
function reset() {
// 重置表单数据或其他状态
currentAddress.value = {} as AddressInfo
userAgree.value = false
authCodeInput.value = ''
// 可能还需要重置其他状态
}
@@ -375,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);
}
/* 移动端优化 */

View File

@@ -1,14 +1,19 @@
<script setup lang="ts">
import type { QAInfo, Setting_QuestionBox, UserInfo } from '@/api/api-models'
import { AddCircle24Regular, DismissCircle24Regular } from '@vicons/fluent'
import { AddCircle24Regular, DismissCircle24Regular, History24Regular } from '@vicons/fluent'
import GraphemeSplitter from 'grapheme-splitter'
import {
NAlert,
NAvatar,
NBadge,
NButton,
NCard,
NCheckbox,
NCollapse,
NCollapseItem,
NDivider,
NDrawer,
NDrawerContent,
NEmpty,
NIcon,
NImage,
@@ -25,6 +30,7 @@ import {
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useStorage } from '@vueuse/core'
import VueTurnstile from 'vue-turnstile'
import { useAccount } from '@/api/account'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
@@ -36,11 +42,34 @@ const { biliInfo, userInfo } = defineProps<{
userInfo: UserInfo | undefined
}>()
// 本地提问历史接口
interface LocalQuestion {
id: string
targetUserId: number
targetUserName: string
message: string
tag: string | null
anonymousName: string
anonymousEmail: string
hasImage: boolean
sendAt: number
}
// 基础状态变量
const message = useMessage()
const accountInfo = useAccount()
const route = useRoute()
const splitter = new GraphemeSplitter()
const isQuestionFormExpanded = ref(false)
// 本地提问历史
const localQuestions = useStorage<LocalQuestion[]>('vtsuru-local-questions', [], undefined, {
serializer: {
read: (v: any) => v ? JSON.parse(v) : [],
write: (v: any) => JSON.stringify(v),
},
})
const showLocalQuestionsDrawer = ref(false)
// 问题相关状态
const questionMessage = ref('')
@@ -54,6 +83,8 @@ const token = ref('')
const turnstile = ref()
const nextSendQuestionTime = ref(Date.now())
const minSendQuestionTime = 30 * 1000
const anonymousName = ref('')
const anonymousEmail = ref('')
// 图片上传相关状态
const targetUserSetting = ref<Setting_QuestionBox | null>(null)
@@ -76,6 +107,12 @@ function countGraphemes(value: string) {
return splitter.countGraphemes(value)
}
function isValidEmail(email: string): boolean {
if (!email) return true // 空邮箱是允许的
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
// 图片处理公共方法
function validateImageFile(file: File): { valid: boolean, message?: string } {
if (file.size > MAX_FILE_SIZE) {
@@ -234,6 +271,12 @@ async function SendQuestion() {
return
}
// 验证邮箱格式
if (anonymousEmail.value && !isValidEmail(anonymousEmail.value)) {
message.error('邮箱格式不正确')
return
}
isSending.value = true
let uploadedFileIds: number[] = []
let imagePayload: { id: number }[] | undefined
@@ -278,6 +321,8 @@ async function SendQuestion() {
Tag: selectedTag.value,
Images: imagePayload,
ImageTokens: tokenPayload ? [tokenPayload] : undefined,
AnonymousName: !isUserLoggedIn.value && anonymousName.value ? anonymousName.value : undefined,
AnonymousEmail: !isUserLoggedIn.value && anonymousEmail.value ? anonymousEmail.value : undefined,
}
const data = await QueryPostAPI<QAInfo>(
@@ -288,11 +333,31 @@ async function SendQuestion() {
if (data.code == 200) {
message.success('成功发送棉花糖')
// 如果是未登录用户,保存到本地历史
if (!isUserLoggedIn.value && userInfo) {
const localQuestion: LocalQuestion = {
id: `local-${Date.now()}-${Math.random().toString(36).substring(7)}`,
targetUserId: userInfo.id,
targetUserName: userInfo.name || '未知用户',
message: questionMessage.value,
tag: selectedTag.value,
anonymousName: anonymousName.value,
anonymousEmail: anonymousEmail.value,
hasImage: !!anonymousImageToken.value,
sendAt: Date.now(),
}
localQuestions.value = [localQuestion, ...localQuestions.value]
}
questionMessage.value = ''
anonymousName.value = ''
anonymousEmail.value = ''
removeAnonymousImage()
clearAllLoggedInImages()
nextSendQuestionTime.value = Date.now() + minSendQuestionTime
getPublicQuestions()
isQuestionFormExpanded.value = false
} else {
message.error(data.message || '发送失败')
if (tokenPayload && (data.message.includes('token') || data.code === 400)) {
@@ -365,6 +430,17 @@ function onSelectTag(tag: string) {
selectedTag.value = selectedTag.value === tag ? null : tag
}
// 本地提问历史管理
function deleteLocalQuestion(id: string) {
localQuestions.value = localQuestions.value.filter(q => q.id !== id)
message.success('已删除')
}
function clearAllLocalQuestions() {
localQuestions.value = []
message.success('已清空所有本地提问记录')
}
// 生命周期钩子
onMounted(() => {
getPublicQuestions()
@@ -379,12 +455,53 @@ onUnmounted(() => {
</script>
<template>
<div class="question-box-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>

View 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. **跨设备**: 记录仅在当前浏览器可见,不跨设备同步
## 使用场景
- 未登录用户想回顾自己发送的提问
- 确认提问是否成功发送
- 管理和清理本地提问记录
- 离线查看已发送的提问内容

View 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. 控制台错误信息(如果有)