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