feat: Enhance HistoryView with Guard List and Stats

- Added guard member model and statistics interface.
- Implemented loading functionality for guard list and statistics.
- Introduced a data table to display current guard members with pagination.
- Enhanced error handling and loading states for guard data.
- Updated UI components to include refresh button and statistics display.

feat: Update PointManage and PointGoodsView for Improved User Experience

- Added management flag to PointGoodsItem for better handling in PointManage.
- Enhanced PointGoodsView with improved tooltip logic for purchase status.
- Implemented detailed purchase history alerts and restrictions based on user actions.
- Improved visual feedback for purchased and non-purchasable items.

docs: Add comprehensive API integration documentation

- Documented API modules, data models, and request types.
- Included details on live platform integration and data storage mechanisms.

docs: Establish development workflow guidelines

- Outlined project configuration, environment setup, and code style practices.
- Provided deployment process using Docker.

docs: Create detailed documentation for the live request system

- Described the functionality and data flow of the song request system.
- Included main files and features related to the song request functionality.

docs: Define project structure and UI components

- Documented the overall project structure and key directories.
- Listed main UI components and their usage within the project.

chore: Analyze and document improvements in AnalyzeView

- Detailed enhancements made to the AnalyzeView for better user experience.
- Included visual comparisons and technical highlights of the optimizations.
This commit is contained in:
Megghy
2025-10-07 14:40:25 +08:00
parent 2966a49fc9
commit 96f6169a6c
16 changed files with 1953 additions and 619 deletions

View File

@@ -0,0 +1,36 @@
---
inclusion: manual
---
# API集成
该项目使用多个API接口与后端服务和直播平台进行交互。
## 主要API模块
- [src/api/api-models.ts](mdc:src/api/api-models.ts): 定义了系统中使用的数据模型
- [src/api/query.ts](mdc:src/api/query.ts): 提供了API请求的基础函数
- [src/api/account.ts](mdc:src/api/account.ts): 账户管理相关API
## 数据模型
- `SongRequestInfo`: 点歌请求信息
- `DanmakuUserInfo`: 弹幕用户信息
- `EventModel`: 事件数据模型用于处理弹幕、SC等事件
- `Setting_LiveRequest`: 点歌系统设置
## API请求类型
- `QueryGetAPI`: GET请求
- `QueryPostAPI`: POST请求
- `QueryPostAPIWithParams`: 带参数的POST请求
## 直播平台集成
系统集成了直播平台如B站的API通过`useDanmakuClient()`获取直播间的弹幕、SC等数据。主要事件类型
- `danmaku`: 弹幕事件
- `sc`: SuperChat事件
## 数据存储
系统使用`useStorage`进行本地数据存储,`useAccount`获取账户信息。远程数据通过API请求获取和更新。

View File

@@ -0,0 +1,35 @@
---
inclusion: always
---
# 开发工作流
## 项目配置
- TypeScript: 项目使用TypeScript进行类型检查
- Vite: 使用Vite作为构建工具
- ESLint: 代码质量检查
- Prettier: 代码格式化
## 主要配置文件
- [package.json](mdc:package.json): 项目依赖和脚本
- [tsconfig.json](mdc:tsconfig.json): TypeScript配置
- [vite.config.mts](mdc:vite.config.mts): Vite构建配置
- [.prettierrc.json](mdc:.prettierrc.json): Prettier格式化配置
- [eslint.config.mjs](mdc:eslint.config.mjs): ESLint配置
## 开发环境
项目运行在Windows环境中使用PowerShell作为默认shell。
## 代码风格
- 使用中文作为用户界面和日志语言
- 注释应尽量简短,必要时使用中文
- 遵循Vue 3组合式API的最佳实践
## 部署流程
项目包含Docker配置可以使用Docker进行部署
- [Dockerfile](mdc:Dockerfile): Docker构建配置

View File

@@ -0,0 +1,28 @@
---
inclusion: manual
---
# 点歌系统
点歌系统是主要功能之一允许观众在直播过程中通过弹幕、SuperChat或网页界面请求歌曲。
## 主要文件
- [src/views/open_live/LiveRequest.vue](mdc:src/views/open_live/LiveRequest.vue): 点歌系统的主要界面组件
- [src/views/obs/LiveRequestOBS.vue](mdc:src/views/obs/LiveRequestOBS.vue): 用于OBS的点歌系统显示组件
## 主要功能
- 支持多种点歌方式弹幕、SuperChat、网页、手动添加
- 歌曲队列管理:等待、演唱中、已完成、已取消等状态管理
- 权限控制:可配置只允许舰长、提督、总督或粉丝牌用户点歌
- 冷却时间:可设置不同用户类型的点歌冷却时间
- OBS集成提供适用于OBS的显示组件可展示当前点歌队列
- 黑名单:可将特定用户加入黑名单
## 数据流
1. 接收来自直播平台的弹幕或SuperChat
2. 通过前缀识别点歌请求(如"点播"
3. 根据规则验证请求是否有效
4. 将有效请求添加到点歌队列
5. 主播可以管理队列:开始演唱、标记完成、取消请求等

View File

@@ -0,0 +1,3 @@
---
inclusion: always
---

View File

@@ -0,0 +1,29 @@
---
inclusion: manual
---
# 项目结构
该项目是一个基于Vue的直播辅助工具主要用于管理直播相关功能如点歌系统、弹幕互动等。
## 主要目录结构
- `src/`: 源代码目录
- `api/`: API调用和模型定义
- `assets/`: 静态资源文件
- `client/`: 客户端相关组件和服务
- `components/`: Vue组件
- `composables/`: Vue组合式API函数
- `data/`: 数据相关模块,包括聊天和弹幕客户端
- `router/`: 路由配置
- `store/`: 状态管理
- `views/`: 页面视图组件
- `open_live/`: 直播相关视图,包括点歌系统
- `obs/`: OBS相关视图组件
- `public/`: 公共静态资源
- `plugins/`: 插件目录
## 主要功能模块
- 点歌系统允许观众通过弹幕或SuperChat点歌
- 直播互动:弹幕互动和自动化操作
- OBS集成为OBS提供overlays和组件

View File

@@ -0,0 +1,50 @@
---
inclusion: fileMatch
fileMatchPattern: ['*.vue']
---
# UI组件
项目使用Vue 3和Naive UI作为主要UI框架采用组件化设计。
## 主要UI框架
- Vue 3: 使用`<script setup>`语法和组合式API
- Naive UI: 提供各种预设UI组件
- VueUse: 提供实用的组合式函数,如`useStorage`
## 常用组件
### Naive UI组件
项目广泛使用Naive UI组件
- `NCard`: 卡片容器
- `NSpace`: 间距布局
- `NButton`: 按钮
- `NInput`: 输入框
- `NTabs`: 标签页
- `NDataTable`: 数据表格
- `NModal`: 模态框
- `NAlert`: 警告提示
- `NTag`: 标签
- `NIcon`: 图标容器
### 自定义组件
- [SongPlayer.vue](mdc:src/components/SongPlayer.vue): 歌曲播放器组件
- [LiveRequestOBS.vue](mdc:src/views/obs/LiveRequestOBS.vue): OBS点歌显示组件
## 状态管理
项目使用组合式API和本地存储管理状态
- `ref`/`computed`: 响应式状态
- `useStorage`: 持久化存储
- `useAccount`: 账户状态管理
## UI设计模式
- 使用`NFlex``NCard`进行布局
- 通过`NTabs`组织不同功能区域
- 使用状态颜色区分不同状态(如等待中、处理中、已完成)
- 响应式设计适应不同屏幕尺寸

View File

@@ -51,6 +51,10 @@ export async function GetSelfAccount(token?: string) {
export function UpdateAccountLoop() {
setInterval(() => {
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.get('as')) {
return
}
if (ACCOUNT.value && window.$route?.name != 'question-display') {
// 防止在问题详情页刷新
GetSelfAccount()

View File

@@ -715,6 +715,12 @@ export interface ResponsePointGoodModel {
allowGuardLevel: GuardLevel
setting: PointGoodsSetting
// 购买状态信息
purchasedCount: number
hasPurchased: boolean
canPurchase: boolean
cannotPurchaseReason?: string
// 添加虚拟礼物多Key支持
virtualKeys?: string[]
keySelectionMode?: KeySelectionMode

7
src/components.d.ts vendored
View File

@@ -19,19 +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']
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

@@ -9,6 +9,7 @@ const props = defineProps<{
goods: ResponsePointGoodModel | undefined
contentStyle?: string | undefined
size?: 'small' | 'default'
isManage?: boolean
}>()
// 默认封面图片
@@ -62,16 +63,16 @@ const emptyCover = `${IMGUR_URL}None.png`
:bordered="true"
style="background-color: transparent;"
:style="{
color: goods.type == GoodsTypes.Physical ? '#006633' : '#0066cc',
borderColor: goods.type == GoodsTypes.Physical ? '#009966' : '#3399ff',
backgroundColor: goods.type == GoodsTypes.Physical ? '#c2e6d290' : '#c2d6eb90',
color: goods.type === GoodsTypes.Physical ? '#006633' : '#0066cc',
borderColor: goods.type === GoodsTypes.Physical ? '#009966' : '#3399ff',
backgroundColor: goods.type === GoodsTypes.Physical ? '#c2e6d290' : '#c2d6eb90',
}"
>
{{ goods.type == GoodsTypes.Physical ? '实物' : '虚拟' }}
{{ goods.type === GoodsTypes.Physical ? '实物' : '虚拟' }}
</NTag>
<!-- 状态标签 -->
<NTag
v-if="goods.count == 0"
v-if="goods.count === 0"
size="small"
type="error"
:bordered="false"
@@ -133,7 +134,7 @@ const emptyCover = `${IMGUR_URL}None.png`
{{ goods.count }}
</NText>
<NText
v-else-if="goods.count == 0"
v-else-if="goods.count === 0"
size="small"
type="error"
>
@@ -174,12 +175,152 @@ const emptyCover = `${IMGUR_URL}None.png`
</NText>
</NEllipsis>
<!-- 礼物信息卡片 - 仅在后台管理页面显示 -->
<div
v-if="isManage"
class="info-cards"
>
<!-- 兑换数量限制 -->
<div
v-if="goods.type === GoodsTypes.Physical && goods.maxBuyCount"
class="info-item"
>
<NText
depth="3"
class="info-label"
>
📦 限购
</NText>
<NText class="info-value">
{{ goods.maxBuyCount }}
</NText>
</div>
<!-- 是否允许重复兑换 -->
<div class="info-item">
<NText
depth="3"
class="info-label"
>
🔄 重购
</NText>
<NText
class="info-value"
:type="goods.isAllowRebuy ? 'success' : 'error'"
>
{{ goods.isAllowRebuy ? '允许' : '禁止' }}
</NText>
</div>
<!-- 舰长等级限制 -->
<div
v-if="goods.setting?.allowGuardLevel && goods.setting.allowGuardLevel > 0"
class="info-item"
>
<NText
depth="3"
class="info-label"
>
等级
</NText>
<NText
class="info-value"
type="warning"
>
{{ goods.setting.allowGuardLevel === 1 ? '总督' : goods.setting.allowGuardLevel === 2 ? '提督' : '舰长' }}+
</NText>
</div>
<!-- 舰长免费 -->
<div
v-if="goods.setting?.guardFree"
class="info-item"
>
<NText
depth="3"
class="info-label"
>
舰长
</NText>
<NText
class="info-value"
type="success"
>
免费
</NText>
</div>
<!-- 虚拟礼物密钥数量 -->
<div
v-if="goods.type === GoodsTypes.Virtual && goods.virtualKeys && goods.virtualKeys.length > 0"
class="info-item"
>
<NText
depth="3"
class="info-label"
>
🔑 密钥
</NText>
<NText class="info-value">
{{ goods.virtualKeys.length }}
</NText>
</div>
<!-- 收集地址方式 -->
<div
v-if="goods.type === GoodsTypes.Physical"
class="info-item"
>
<NText
depth="3"
class="info-label"
>
📮 地址
</NText>
<NText class="info-value">
{{ goods.collectUrl ? '站外' : '本站' }}
</NText>
</div>
</div>
<!-- 用户自定义标签展示 -->
<div
v-if="goods.tags && goods.tags.length > 0"
v-if="(goods.tags && goods.tags.length > 0) || (!isManage && ((goods.setting?.allowGuardLevel ?? 0) > 0 || goods.setting?.guardFree || !goods.isAllowRebuy))"
class="tags-container"
>
<div class="tags-wrapper">
<!-- 用户页面显示重要信息标签 -->
<template v-if="!isManage">
<NTag
v-if="goods.setting?.allowGuardLevel && goods.setting.allowGuardLevel > 0"
:bordered="false"
size="tiny"
class="user-tag important-tag"
style="color: #fff; background-color: rgba(255, 170, 0, 0.85);"
>
{{ goods.setting.allowGuardLevel === 1 ? '总督' : goods.setting.allowGuardLevel === 2 ? '提督' : '舰长' }}+
</NTag>
<NTag
v-if="goods.setting?.guardFree"
:bordered="false"
size="tiny"
class="user-tag important-tag"
style="color: #fff; background-color: rgba(24, 160, 88, 0.85);"
>
舰长免费
</NTag>
<NTag
v-if="!goods.isAllowRebuy"
:bordered="false"
size="tiny"
class="user-tag important-tag"
style="color: #fff; background-color: rgba(208, 48, 80, 0.85);"
>
🔒 限购一次
</NTag>
</template>
<!-- 用户自定义标签 -->
<NTag
v-for="tag in goods.tags"
:key="tag"
@@ -399,6 +540,18 @@ const emptyCover = `${IMGUR_URL}None.png`
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
/* 重要信息标签样式 */
.important-tag {
font-weight: 600;
letter-spacing: 0.3px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.important-tag:hover {
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25);
transform: translateY(-2px) scale(1.08);
}
.stock-info {
font-size: 0.85em;
color: var(--text-color-3);
@@ -408,4 +561,45 @@ const emptyCover = `${IMGUR_URL}None.png`
border-radius: 4px;
font-weight: 500;
}
/* 信息卡片样式 */
.info-cards {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0;
padding: 8px;
background-color: var(--action-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.info-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: var(--card-color);
border-radius: 4px;
border: 1px solid var(--divider-color);
transition: all 0.2s ease;
font-size: 0.85em;
}
.info-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color-hover);
}
.info-label {
font-size: 0.9em;
white-space: nowrap;
}
.info-value {
font-weight: 600;
font-size: 0.95em;
white-space: nowrap;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -764,7 +764,7 @@ onMounted(() => {
<NLayout>
<!-- 主内容区域 -->
<NScrollbar :style="`height: calc(100vh - var(--vtsuru-header-height) - ${aplayerHeight}px)`">
<NLayoutContent content-style="margin: var(--vtsuru-content-padding); margin-right: calc(var(--vtsuru-content-padding) + 4px)">
<NLayoutContent content-style="margin: var(--vtsuru-content-padding); margin-right: calc(var(--vtsuru-content-padding) + 4px); padding-bottom: 32px;">
<NElement>
<!-- 已验证邮箱的用户显示内容 -->
<RouterView

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { TrendingDown, TrendingUp } from '@vicons/ionicons5'
import { RefreshOutline, TrendingDown, TrendingUp } from '@vicons/ionicons5'
import { BarChart, LineChart } from 'echarts/charts'
import {
DataZoomComponent,
@@ -12,7 +12,7 @@ import {
} from 'echarts/components'
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { NCard, NDivider, NGrid, NGridItem, NIcon, NSpace, NSpin, NStatistic, NTabPane, NTabs, NTag, useMessage, useThemeVars } from 'naive-ui'
import { NButton, NCard, NDivider, NEmpty, NGrid, NGridItem, NIcon, NSpace, NSpin, NStatistic, NTabPane, NTabs, NTag, NTime, NTooltip, useMessage, useThemeVars } from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { QueryGetAPI } from '@/api/query'
@@ -83,14 +83,17 @@ interface AnalyzeData {
// 状态管理
const loading = ref(true)
const refreshing = ref(false)
const message = useMessage()
const analyzeData = ref<AnalyzeData>()
const summaryData = computed(() => analyzeData.value?.summary)
const activeChart = ref('income')
const themeVars = useThemeVars()
const lastUpdateTime = ref<number>(0)
const hasData = computed(() => analyzeData.value && Object.keys(analyzeData.value.chartData || {}).length > 0)
// 处理标签页变化
function onTabChange(value: string) {
function onTabChange(_value: string) {
nextTick(() => {
handleResize()
})
@@ -212,7 +215,7 @@ function initCharts() {
grid: {
left: '3%',
right: '4%',
bottom: '3%',
bottom: '15%',
top: '60px',
containLabel: true,
},
@@ -418,14 +421,22 @@ function updateChartTheme() {
}
// 获取分析数据
async function fetchAnalyzeData() {
async function fetchAnalyzeData(isRefresh = false) {
try {
loading.value = true
if (isRefresh) {
refreshing.value = true
} else {
loading.value = true
}
const data = await QueryGetAPI<AnalyzeData>(`${ANALYZE_API_URL}all`)
if (data.code === 200) {
analyzeData.value = data.data
lastUpdateTime.value = Date.now()
nextTick(() => initCharts())
if (isRefresh) {
message.success('数据已刷新')
}
} else {
message.error(`获取数据失败: ${data.message}`)
}
@@ -434,9 +445,15 @@ async function fetchAnalyzeData() {
console.error('获取数据出错:', error)
} finally {
loading.value = false
refreshing.value = false
}
}
// 刷新数据
function handleRefresh() {
fetchAnalyzeData(true)
}
// 窗口大小变化时重绘图表
function handleResize() {
incomeChart?.resize()
@@ -465,246 +482,395 @@ onUnmounted(() => {
</script>
<template>
<div>
<div class="analyze-container">
<!-- 顶部操作栏 -->
<NSpace
align="center"
justify="space-between"
class="header-actions"
>
<EventFetcherAlert />
<EventFetcherStatusCard />
<NSpace align="center">
<EventFetcherAlert />
<EventFetcherStatusCard />
</NSpace>
<NSpace align="center">
<NTooltip v-if="lastUpdateTime > 0">
<template #trigger>
<NTag size="small" :bordered="false">
<NIcon :component="TrendingUp" style="margin-right: 4px;" />
<NTime :time="lastUpdateTime" type="relative" />更新
</NTag>
</template>
<NTime :time="lastUpdateTime" />
</NTooltip>
<NButton
:loading="refreshing"
:disabled="loading"
@click="handleRefresh"
>
<template #icon>
<NIcon :component="RefreshOutline" />
</template>
刷新数据
</NButton>
</NSpace>
</NSpace>
<NDivider />
<NSpin :show="loading">
<!-- 数据概览卡片 -->
<div class="summary-cards">
<NGrid
cols="1 800:2 1200:3"
:x-gap="12"
:y-gap="12"
>
<NGridItem>
<NCard
title="近7天统计"
size="small"
class="summary-card"
>
<div class="stat-grid">
<NStatistic
label="总收入"
:value="formatCurrency(summaryData?.last7Days?.totalIncome || 0)"
/>
<NStatistic
label="总互动数"
:value="summaryData?.last7Days?.totalInteractions || 0"
/>
<NStatistic
label="弹幕数"
:value="summaryData?.last7Days?.totalDanmakuCount || 0"
/>
<NStatistic
label="直播时长(小时)"
:value="((summaryData?.last7Days?.totalLiveMinutes || 0) / 60).toFixed(1)"
/>
<NStatistic
label="互动人数"
:value="summaryData?.last7Days?.interactionUsers || 0"
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last7Days?.interactionUsersTrend || 0)"
size="small"
>
{{ formatTrend(summaryData?.last7Days?.interactionUsersTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="付费人数"
:value="summaryData?.last7Days?.payingUsers || 0"
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last7Days?.payingUsersTrend || 0)"
size="small"
>
{{ formatTrend(summaryData?.last7Days?.payingUsersTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="日均收入"
:value="formatCurrency(summaryData?.last7Days?.dailyAvgIncome || 0)"
/>
<NStatistic
label="日均弹幕"
:value="summaryData?.last7Days?.dailyAvgDanmaku || 0"
/>
<NStatistic
label="活跃直播天数"
:value="summaryData?.last7Days?.activeLiveDays || 0"
/>
</div>
</NCard>
</NGridItem>
<NGridItem>
<NCard
title="近30天统计"
size="small"
class="summary-card"
>
<div class="stat-grid">
<NStatistic
label="总收入"
:value="formatCurrency(summaryData?.last30Days?.totalIncome || 0)"
/>
<NStatistic
label="总互动数"
:value="summaryData?.last30Days?.totalInteractions || 0"
/>
<NStatistic
label="弹幕数"
:value="summaryData?.last30Days?.totalDanmakuCount || 0"
/>
<NStatistic
label="直播时长(小时)"
:value="((summaryData?.last30Days?.totalLiveMinutes || 0) / 60).toFixed(1)"
/>
<NStatistic
label="互动人数"
:value="summaryData?.last30Days?.interactionUsers || 0"
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last30Days?.interactionTrend || 0)"
size="small"
>
{{ formatTrend(summaryData?.last30Days?.interactionTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="付费人数"
:value="summaryData?.last30Days?.payingUsers || 0"
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last30Days?.incomeTrend || 0)"
size="small"
>
{{ formatTrend(summaryData?.last30Days?.incomeTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="日均收入"
:value="formatCurrency(summaryData?.last30Days?.dailyAvgIncome || 0)"
/>
<NStatistic
label="日均弹幕"
:value="summaryData?.last30Days?.dailyAvgDanmaku || 0"
/>
<NStatistic
label="活跃直播天数"
:value="summaryData?.last30Days?.activeLiveDays || 0"
/>
</div>
</NCard>
</NGridItem>
<NGridItem>
<NCard
title="关键指标"
size="small"
class="summary-card"
>
<div class="stat-grid">
<NStatistic
label="月收入增长"
:value="formatTrend(summaryData?.last30Days?.incomeTrend || 0)"
>
<template #prefix>
<NIcon :color="(summaryData?.last30Days?.incomeTrend || 0) >= 0 ? '#18A058' : '#D03050'">
<TrendingUp v-if="(summaryData?.last30Days?.incomeTrend || 0) >= 0" />
<TrendingDown v-else />
</NIcon>
</template>
</NStatistic>
<NStatistic
label="月互动增长"
:value="formatTrend(summaryData?.last30Days?.interactionTrend || 0)"
>
<template #prefix>
<NIcon
:component="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? TrendingUp : TrendingDown"
:color="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? '#18A058' : '#D03050'"
>
<TrendingUp v-if="(summaryData?.last30Days?.interactionTrend || 0) >= 0" />
<TrendingDown v-else />
</NIcon>
</template>
</NStatistic>
<NStatistic
label="单次直播平均时长"
:value="`${((summaryData?.last30Days?.totalLiveMinutes || 0) / (summaryData?.last30Days?.activeLiveDays || 1) / 60).toFixed(1)}小时`"
/>
<NStatistic
label="互动转化率"
:value="`${((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100).toFixed(1)}%`"
/>
<NStatistic
label="每付费用户平均打米"
:value="formatCurrency((summaryData?.last30Days?.totalIncome || 0) / (summaryData?.last30Days?.payingUsers || 1))"
/>
</div>
</NCard>
</NGridItem>
</NGrid>
</div>
<NDivider />
<!-- 图表选择器 -->
<div class="chart-selector">
<NTabs
v-model:value="activeChart"
type="line"
animated
@update:value="onTabChange"
>
<NTabPane
name="income"
tab="收入分析"
display-directive="show"
<!-- 空状态 -->
<NEmpty
v-if="!loading && !hasData"
description="暂无数据"
size="large"
style="margin: 60px 0"
>
<template #extra>
<NButton @click="() => fetchAnalyzeData()">
重新加载
</NButton>
</template>
</NEmpty>
<!-- 数据展示 -->
<template v-else>
<!-- 数据概览卡片 -->
<div class="summary-cards">
<NGrid
cols="1 800:2 1200:3"
:x-gap="16"
:y-gap="16"
>
<div
ref="incomeChartRef"
class="chart"
/>
</NTabPane>
<NTabPane
name="interaction"
tab="互动分析"
display-directive="show"
<NGridItem>
<NCard
title="近7天统计"
size="small"
class="summary-card"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="info">
最近一周
</NTag>
</template>
<div class="stat-grid">
<NStatistic
label="总收入"
tabular-nums
>
<template #default>
<span class="stat-value-primary">
{{ formatCurrency(summaryData?.last7Days?.totalIncome || 0) }}
</span>
</template>
</NStatistic>
<NStatistic
label="总互动数"
:value="summaryData?.last7Days?.totalInteractions || 0"
tabular-nums
/>
<NStatistic
label="弹幕数"
:value="summaryData?.last7Days?.totalDanmakuCount || 0"
tabular-nums
/>
<NStatistic
label="直播时长"
tabular-nums
>
<template #default>
{{ ((summaryData?.last7Days?.totalLiveMinutes || 0) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<NStatistic
label="互动人数"
:value="summaryData?.last7Days?.interactionUsers || 0"
tabular-nums
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last7Days?.interactionUsersTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last7Days?.interactionUsersTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last7Days?.interactionUsersTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last7Days?.interactionUsersTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="付费人数"
:value="summaryData?.last7Days?.payingUsers || 0"
tabular-nums
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last7Days?.payingUsersTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last7Days?.payingUsersTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last7Days?.payingUsersTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last7Days?.payingUsersTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="日均收入"
tabular-nums
>
<template #default>
{{ formatCurrency(summaryData?.last7Days?.dailyAvgIncome || 0) }}
</template>
</NStatistic>
<NStatistic
label="日均弹幕"
:value="(summaryData?.last7Days?.dailyAvgDanmaku || 0).toFixed(0)"
tabular-nums
/>
<NStatistic
label="活跃直播天数"
:value="summaryData?.last7Days?.activeLiveDays || 0"
tabular-nums
/>
</div>
</NCard>
</NGridItem>
<NGridItem>
<NCard
title="近30天统计"
size="small"
class="summary-card"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="warning">
最近一月
</NTag>
</template>
<div class="stat-grid">
<NStatistic
label="总收入"
tabular-nums
>
<template #default>
<span class="stat-value-primary">
{{ formatCurrency(summaryData?.last30Days?.totalIncome || 0) }}
</span>
</template>
</NStatistic>
<NStatistic
label="总互动数"
:value="summaryData?.last30Days?.totalInteractions || 0"
tabular-nums
/>
<NStatistic
label="弹幕数"
:value="summaryData?.last30Days?.totalDanmakuCount || 0"
tabular-nums
/>
<NStatistic
label="直播时长"
tabular-nums
>
<template #default>
{{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<NStatistic
label="互动人数"
:value="summaryData?.last30Days?.interactionUsers || 0"
tabular-nums
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last30Days?.interactionTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last30Days?.interactionTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last30Days?.interactionTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last30Days?.interactionTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="付费人数"
:value="summaryData?.last30Days?.payingUsers || 0"
tabular-nums
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last30Days?.incomeTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last30Days?.incomeTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last30Days?.incomeTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last30Days?.incomeTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="日均收入"
tabular-nums
>
<template #default>
{{ formatCurrency(summaryData?.last30Days?.dailyAvgIncome || 0) }}
</template>
</NStatistic>
<NStatistic
label="日均弹幕"
:value="(summaryData?.last30Days?.dailyAvgDanmaku || 0).toFixed(0)"
tabular-nums
/>
<NStatistic
label="活跃直播天数"
:value="summaryData?.last30Days?.activeLiveDays || 0"
tabular-nums
/>
</div>
</NCard>
</NGridItem>
<NGridItem>
<NCard
title="关键指标"
size="small"
class="summary-card summary-card-highlight"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="success">
核心数据
</NTag>
</template>
<div class="stat-grid">
<NStatistic
label="月收入增长"
tabular-nums
>
<template #default>
<span class="trend-value" :class="(summaryData?.last30Days?.incomeTrend || 0) >= 0 ? 'trend-up' : 'trend-down'">
{{ formatTrend(summaryData?.last30Days?.incomeTrend || 0) }}
</span>
</template>
<template #prefix>
<NIcon :color="(summaryData?.last30Days?.incomeTrend || 0) >= 0 ? '#18A058' : '#D03050'">
<TrendingUp v-if="(summaryData?.last30Days?.incomeTrend || 0) >= 0" />
<TrendingDown v-else />
</NIcon>
</template>
</NStatistic>
<NStatistic
label="月互动增长"
tabular-nums
>
<template #default>
<span class="trend-value" :class="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? 'trend-up' : 'trend-down'">
{{ formatTrend(summaryData?.last30Days?.interactionTrend || 0) }}
</span>
</template>
<template #prefix>
<NIcon
:component="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? TrendingUp : TrendingDown"
:color="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? '#18A058' : '#D03050'"
/>
</template>
</NStatistic>
<NStatistic
label="单次直播平均时长"
tabular-nums
>
<template #default>
{{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / (summaryData?.last30Days?.activeLiveDays || 1) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<NStatistic
label="互动转化率"
tabular-nums
>
<template #default>
{{ ((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100).toFixed(1) }}%
</template>
</NStatistic>
<NStatistic
label="每付费用户平均收入"
tabular-nums
>
<template #default>
{{ formatCurrency((summaryData?.last30Days?.totalIncome || 0) / (summaryData?.last30Days?.payingUsers || 1)) }}
</template>
</NStatistic>
</div>
</NCard>
</NGridItem>
</NGrid>
</div>
<NDivider />
<!-- 图表选择器 -->
<div class="chart-selector">
<NTabs
v-model:value="activeChart"
type="line"
animated
@update:value="onTabChange"
>
<div
ref="interactionChartRef"
class="chart"
/>
</NTabPane>
<NTabPane
name="users"
tab="用户分析"
display-directive="show"
>
<div
ref="usersChartRef"
class="chart"
/>
</NTabPane>
</NTabs>
</div>
<NDivider />
<NTabPane
name="income"
tab="收入分析"
display-directive="show"
>
<div
ref="incomeChartRef"
class="chart"
/>
</NTabPane>
<NTabPane
name="interaction"
tab="互动分析"
display-directive="show"
>
<div
ref="interactionChartRef"
class="chart"
/>
</NTabPane>
<NTabPane
name="users"
tab="用户分析"
display-directive="show"
>
<div
ref="usersChartRef"
class="chart"
/>
</NTabPane>
</NTabs>
</div>
</template>
</NSpin>
</div>
</template>
<style scoped>
.analyze-container {
width: 100%;
max-width: 1600px;
margin: 0 auto;
padding: 0 4px;
}
.header-actions {
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.analyze-card {
margin-bottom: 20px;
}
@@ -715,33 +881,154 @@ onUnmounted(() => {
.summary-card {
height: 100%;
transition: all 0.3s;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 8px;
}
.summary-card:hover {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-4px);
}
.summary-card-highlight {
background: linear-gradient(135deg, rgba(24, 160, 88, 0.03) 0%, rgba(24, 160, 88, 0.01) 100%);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
gap: 16px;
}
.stat-value-primary {
font-size: 1.2em;
font-weight: 600;
color: var(--n-text-color);
}
.trend-value {
font-weight: 600;
font-size: 1.1em;
transition: all 0.3s;
}
.trend-up {
color: #18A058;
}
.trend-down {
color: #D03050;
}
.chart-selector {
margin-top: 20px;
border-radius: 8px;
overflow: hidden;
}
.chart {
height: 450px;
height: 500px;
width: 100%;
margin-top: 10px;
transition: height 0.3s ease;
}
/* 响应式设计 */
@media (max-width: 1400px) {
.chart {
height: 450px;
}
}
@media (max-width: 1024px) {
.chart {
height: 400px;
}
}
@media (max-width: 768px) {
.analyze-container {
padding: 0;
}
.stat-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.chart {
height: 350px;
}
.header-actions {
flex-direction: column;
align-items: stretch !important;
}
.header-actions :deep(.n-space) {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 480px) {
.chart {
height: 300px;
}
.stat-value-primary {
font-size: 1.1em;
}
.trend-value {
font-size: 1em;
}
}
/* 骨架屏动画 */
@keyframes skeleton-loading {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
/* 标签优化 */
:deep(.n-statistic-value__prefix) {
margin-right: 8px;
display: inline-flex;
align-items: center;
}
:deep(.n-statistic-value__suffix) {
margin-left: 8px;
}
/* 图表容器优化 */
:deep(.n-tabs-nav) {
padding: 0 12px;
}
/* 卡片标题优化 */
:deep(.n-card-header__main) {
font-weight: 600;
font-size: 1.05em;
}
/* Hover效果 */
.summary-card :deep(.n-statistic) {
transition: transform 0.2s;
}
.summary-card:hover :deep(.n-statistic) {
transform: scale(1.02);
}
</style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { DataTableColumns } from 'naive-ui'
import { Info24Filled } from '@vicons/fluent'
import { addDays, endOfDay, format, startOfDay } from 'date-fns'
import { BarChart, LineChart } from 'echarts/charts'
@@ -12,8 +13,8 @@ import {
} from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { NAlert, NButton, NCard, NDatePicker, NDivider, NIcon, NSpace, NSpin, NText, NTime, NTooltip, useMessage } from 'naive-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { NAlert, NButton, NCard, NDataTable, NDatePicker, NDivider, NEmpty, NIcon, NSpace, NSpin, NText, NTime, NTooltip, useMessage } from 'naive-ui'
import { computed, h, onMounted, ref, watch } from 'vue'
import VChart from 'vue-echarts'
import { useAccount } from '@/api/account'
import { QueryGetAPI } from '@/api/query'
@@ -62,6 +63,25 @@ interface HistoryUpstatRecordModel {
}
}
interface GuardMemberModel {
guardUid: number
username: string
guardLevel: string
accompanyDays: number
isActive: boolean
lastUpdateTime: string
}
interface GuardStatsModel {
totalCount: number
governorCount: number
admiralCount: number
captainCount: number
avgAccompanyDays: number
maxAccompanyDays: number
lastUpdateTime: string
}
const accountInfo = useAccount()
const message = useMessage()
@@ -83,6 +103,66 @@ const upstatLikeOption = ref()
const isLoading = ref(true)
// 舰长列表相关
const guardList = ref<GuardMemberModel[]>([])
const guardStats = ref<GuardStatsModel | null>(null)
const guardListLoading = ref(false)
const guardPaginationPage = ref(1)
const guardPaginationPageSize = ref(30)
const guardPagination = computed(() => ({
page: guardPaginationPage.value,
pageSize: guardPaginationPageSize.value,
itemCount: guardList.value.length,
showSizePicker: true,
pageSizes: [10, 20, 30, 50],
onChange: (page: number) => {
guardPaginationPage.value = page
},
onUpdatePageSize: (pageSize: number) => {
guardPaginationPageSize.value = pageSize
guardPaginationPage.value = 1
},
}))
// 舰长列表表格列定义
const guardColumns: DataTableColumns<GuardMemberModel> = [
{
title: 'UID',
key: 'guardUid',
width: 100,
},
{
title: '用户名',
key: 'username',
ellipsis: {
tooltip: true,
},
},
{
title: '等级',
key: 'guardLevel',
width: 80,
render: (row) => {
const colorMap: Record<string, string> = {
总督: '#FF6B9D',
提督: '#C59AFF',
舰长: '#00D1FF',
}
return h(
'span',
{ style: { color: colorMap[row.guardLevel] || '#333', fontWeight: 'bold' } },
row.guardLevel,
)
},
},
{
title: '陪伴天数',
key: 'accompanyDays',
width: 100,
sorter: (a, b) => a.accompanyDays - b.accompanyDays,
},
]
// 统计开始日期
const statisticStartDate = new Date(2023, 10, 4)
const statisticStartDateTime = statisticStartDate.getTime()
@@ -119,7 +199,7 @@ const chartHeight = computed(() => {
async function getHistory() {
try {
const response = await QueryGetAPI<HistoryModel>(`${HISTORY_API_URL}get-all`)
if (response.code == 200) {
if (response.code === 200) {
fansHistory.value = response.data.fan.records
guardHistory.value = response.data.guard.records
upstatHistory.value = response.data.upstat.records
@@ -129,7 +209,7 @@ async function getHistory() {
} else {
message.error(`加载失败: ${response.message}`)
}
} catch (err) {
} catch {
message.error('加载失败')
}
}
@@ -200,7 +280,7 @@ function generateTimeSeries(
}
if ((historyData[lastTimeIndex + 1]?.time ?? Number.MAX_VALUE) > dayEndTime) {
const changed = data.count !== lastDayCount
const _changed = data.count !== lastDayCount
lastDayCount = data.count
dayExist = true
break
@@ -267,8 +347,8 @@ function processFansChartOptions() {
let str = ''
for (let i = 0; i < param.length; i++) {
const status
= param[i].seriesName == '粉丝数' ? (completeTimeSeries[param[i].dataIndex].exist ? '' : '(未获取)') : ''
const statusHtml = status == '' ? '' : `&nbsp;<span style="color:gray">${status}</span>`
= param[i].seriesName === '粉丝数' ? (completeTimeSeries[param[i].dataIndex].exist ? '' : '(未获取)') : ''
const statusHtml = status === '' ? '' : `&nbsp;<span style="color:gray">${status}</span>`
str += `${param[i].marker + param[i].seriesName}${param[i].data}${statusHtml}<br>`
}
return name + str
@@ -467,6 +547,38 @@ function processUpstatLikeChartOptions() {
upstatLikeOption.value = processUpstatChartOptions('likes', '点赞数')
}
/**
* 加载舰长列表
*/
async function loadGuardList() {
guardListLoading.value = true
try {
const [listResponse, statsResponse] = await Promise.all([
QueryGetAPI<GuardMemberModel[]>(
`${HISTORY_API_URL}guards-list?activeOnly=true`,
),
QueryGetAPI<GuardStatsModel>(`${HISTORY_API_URL}guards/stats`),
])
if (listResponse.code === 200) {
guardList.value = listResponse.data
} else {
message.error(`加载舰长列表失败: ${listResponse.message}`)
}
if (statsResponse.code === 200) {
guardStats.value = statsResponse.data
} else {
message.error(`加载舰长统计失败: ${statsResponse.message}`)
}
} catch (err) {
message.error('加载舰长数据失败')
console.error(err)
} finally {
guardListLoading.value = false
}
}
/**
* 处理所有图表选项
*/
@@ -478,9 +590,10 @@ function processAllChartOptions() {
}
onMounted(async () => {
if (accountInfo.value?.isBiliVerified == true) {
if (accountInfo.value?.isBiliVerified === true) {
await getHistory()
processAllChartOptions()
await loadGuardList() // 加载舰长列表
isLoading.value = false
}
})
@@ -496,7 +609,7 @@ watch(
<template>
<NAlert
v-if="accountInfo?.isBiliVerified != true"
v-if="accountInfo?.isBiliVerified !== true"
type="info"
>
尚未进行Bilibili认证
@@ -622,6 +735,45 @@ watch(
:style="{ height: chartHeight }"
class="chart"
/>
<!-- 舰长列表 -->
<NCard
title="当前在舰用户"
size="small"
style="margin-top: 16px"
>
<NSpace
vertical
size="small"
>
<NSpace align="center">
<NButton
type="primary"
:loading="guardListLoading"
@click="loadGuardList"
>
刷新列表
</NButton>
<NText v-if="guardStats">
总计: {{ guardStats.totalCount }} (总督: {{ guardStats.governorCount }}, 提督: {{ guardStats.admiralCount }}, 舰长: {{ guardStats.captainCount }})
</NText>
</NSpace>
<NDataTable
v-if="guardList?.length > 0"
:columns="guardColumns"
:data="guardList"
:pagination="guardPagination"
:bordered="false"
size="small"
/>
<NEmpty
v-else-if="!guardListLoading"
description="暂无在舰用户"
/>
</NSpace>
</NCard>
<NDivider />
<!-- <NDivider>
投稿播放量

View File

@@ -562,6 +562,7 @@ onMounted(() => { })
>
<PointGoodsItem
:goods="item"
:is-manage="true"
class="point-goods-card"
>
<template #footer>
@@ -641,6 +642,7 @@ onMounted(() => { })
>
<PointGoodsItem
:goods="item"
:is-manage="true"
class="point-goods-card"
>
<template #footer>

View File

@@ -101,13 +101,24 @@ const addressOptions = computed(() => {
// 判断是否可以执行购买操作
const canDoBuy = computed(() => {
if (!currentGoods.value) return false
// 优先使用后端返回的购买状态
if (!currentGoods.value.canPurchase) return false
// 额外的前端检查
// 检查购买数量是否超出限制
const totalCount = (currentGoods.value.purchasedCount ?? 0) + buyCount.value
if (totalCount > (currentGoods.value.maxBuyCount ?? Number.MAX_VALUE)) return false
// 检查积分是否足够
const pointCheck = currentGoods.value.price * buyCount.value <= currentPoint.value
// 如果是实物礼物且没有外部收集链接,则必须选择地址
const addressCheck
= currentGoods.value.type !== GoodsTypes.Physical
|| currentGoods.value.collectUrl
|| !!selectedAddress.value
return pointCheck && addressCheck
})
@@ -177,18 +188,26 @@ const selectedItems = computed(() => {
// --- 方法 ---
// 获取礼物兑换按钮的提示文本
function getTooltip(goods: ResponsePointGoodModel): '开始兑换' | '当前积分不足' | '请先进行账号认证' | '库存不足' | '舰长等级不足' | '兑换时间未到' | '已达兑换上限' | '需要设置地址' {
if (!biliAuth.value.id) return '请先进行账号认证' // 未认证
if ((goods?.count ?? Number.MAX_VALUE) <= 0) return '库存不足' // 库存不足
if ((currentPoint.value ?? 0) < goods.price && !goods.canFreeBuy) return '当前积分不足' // 积分不足且不能免费兑换
function getTooltip(goods: ResponsePointGoodModel): string {
// 优先使用后端返回的购买状态信息
if (!goods.canPurchase && goods.cannotPurchaseReason) {
return goods.cannotPurchaseReason
}
// 后备检查逻辑
if (!biliAuth.value.id) return '请先进行账号认证'
if ((goods?.count ?? Number.MAX_VALUE) <= 0) return '库存不足'
if (!goods.isAllowRebuy && goods.hasPurchased) return '该礼物不允许重复兑换'
if (goods.purchasedCount >= (goods.maxBuyCount ?? Number.MAX_VALUE)) return `已达兑换上限(${goods.maxBuyCount})`
if ((currentPoint.value ?? 0) < goods.price && !goods.canFreeBuy) return '当前积分不足'
// 检查舰长等级要求
// 使用 guardInfo 判断用户在当前主播房间的舰长等级
const currentGuardLevel = biliAuth.value.guardInfo?.[props.userInfo.id] ?? 0
if (goods.allowGuardLevel > 0 && currentGuardLevel < goods.allowGuardLevel) {
return '舰长等级不足'
}
return '开始兑换' // 可以兑换
return '开始兑换'
}
// 重置购买模态框状态
@@ -219,6 +238,20 @@ async function buyGoods() {
message.error('兑换数量必须为整数')
return
}
// 检查后端购买状态
if (!currentGoods.value?.canPurchase) {
message.error(currentGoods.value?.cannotPurchaseReason || '无法兑换该礼物')
return
}
// 检查是否超出兑换次数限制
const totalCount = (currentGoods.value.purchasedCount ?? 0) + buyCount.value
if (totalCount > (currentGoods.value.maxBuyCount ?? Number.MAX_VALUE)) {
message.error(`超出最大兑换次数限制(${currentGoods.value.maxBuyCount})`)
return
}
if (
currentGoods.value?.type === GoodsTypes.Physical // 是实物
&& !currentGoods.value.collectUrl // 没有外部收集链接
@@ -559,28 +592,59 @@ onMounted(async () => {
:goods="item"
content-style="max-width: 300px; min-width: 250px; height: 380px;"
class="goods-item"
:class="{ 'pinned-item': item.isPinned }"
:class="{
'pinned-item': item.isPinned,
'purchased-item': item.hasPurchased,
'cannot-purchase-item': !item.canPurchase,
}"
>
<template #footer>
<NFlex
justify="space-between"
align="center"
class="goods-footer"
vertical
:size="8"
>
<NTooltip placement="bottom">
<template #trigger>
<NButton
:disabled="getTooltip(item) !== '开始兑换'"
size="small"
type="primary"
class="exchange-btn"
@click="onBuyClick(item)"
>
{{ item.isPinned ? '🔥 兑换' : '兑换' }}
</NButton>
</template>
{{ getTooltip(item) }}
</NTooltip>
<NFlex
v-if="item.hasPurchased || !item.canPurchase"
:size="4"
wrap
>
<NTag
v-if="item.hasPurchased"
:type="item.isAllowRebuy ? 'info' : 'warning'"
size="small"
:bordered="false"
>
{{ item.isAllowRebuy ? `已兑换 ${item.purchasedCount}` : '已兑换' }}
</NTag>
<NTag
v-if="!item.canPurchase && item.cannotPurchaseReason"
type="error"
size="small"
:bordered="false"
>
{{ item.cannotPurchaseReason }}
</NTag>
</NFlex>
<NFlex
justify="space-between"
align="center"
class="goods-footer"
>
<NTooltip placement="bottom">
<template #trigger>
<NButton
:disabled="getTooltip(item) !== '开始兑换'"
size="small"
type="primary"
class="exchange-btn"
@click="onBuyClick(item)"
>
{{ item.isPinned ? '🔥 兑换' : '兑换' }}
</NButton>
</template>
{{ getTooltip(item) }}
</NTooltip>
</NFlex>
</NFlex>
</template>
</PointGoodsItem>
@@ -620,6 +684,23 @@ onMounted(async () => {
content-style="height: auto;"
/>
<!-- 购买历史提示 -->
<NAlert
v-if="currentGoods.hasPurchased"
:type="currentGoods.isAllowRebuy ? 'info' : 'warning'"
style="margin-top: 12px;"
>
<template #header>
{{ currentGoods.isAllowRebuy ? '购买记录' : '重要提示' }}
</template>
你已兑换过此礼物 <strong>{{ currentGoods.purchasedCount }}</strong>
<span v-if="!currentGoods.isAllowRebuy">该礼物不允许重复兑换</span>
<span v-else-if="currentGoods.maxBuyCount">
最多可兑换 <strong>{{ currentGoods.maxBuyCount }}</strong>
(剩余 <strong>{{ currentGoods.maxBuyCount - currentGoods.purchasedCount }}</strong> )
</span>
</NAlert>
<!-- 兑换选项 (仅对实物或需要数量选择的礼物显示) -->
<template v-if="currentGoods.type === GoodsTypes.Physical || (currentGoods.maxBuyCount ?? 1) > 1 || true">
<NDivider style="margin-top: 12px; margin-bottom: 12px;">
@@ -636,7 +717,10 @@ onMounted(async () => {
<NInputNumber
v-model:value="buyCount"
:min="1"
:max="currentGoods.maxBuyCount ?? 100000"
:max="Math.min(
currentGoods.maxBuyCount ?? 100000,
(currentGoods.maxBuyCount ?? 100000) - (currentGoods.purchasedCount ?? 0),
)"
style="max-width: 120px"
step="1"
:precision="0"
@@ -645,7 +729,13 @@ onMounted(async () => {
depth="3"
style="margin-left: 8px;"
>
(最多可兑换 {{ currentGoods.maxBuyCount ?? '无限' }} )
({{
currentGoods.hasPurchased
? `已兑换 ${currentGoods.purchasedCount} 个,还可兑换 ${
(currentGoods.maxBuyCount ?? 100000) - (currentGoods.purchasedCount ?? 0)
}`
: `最多可兑换 ${currentGoods.maxBuyCount ?? '无限'}`
}})
</NText>
</NFormItem>
<!-- 地址选择 (仅对无外部收集链接的实物礼物显示) -->
@@ -688,7 +778,11 @@ onMounted(async () => {
<NDivider style="margin-top: 16px; margin-bottom: 16px;">
<NTag :type="!canDoBuy ? 'error' : 'success'">
{{ !canDoBuy ? (currentGoods.price * buyCount > currentPoint ? '积分不足' : '信息不完整') : '可兑换' }}
{{
!canDoBuy
? (currentGoods.cannotPurchaseReason || (currentGoods.price * buyCount > currentPoint ? '积分不足' : '信息不完整'))
: '可兑换'
}}
</NTag>
</NDivider>
@@ -879,6 +973,20 @@ onMounted(async () => {
content: none;
}
.purchased-item {
opacity: 0.92;
}
.cannot-purchase-item {
opacity: 0.7;
filter: grayscale(0.3);
}
.cannot-purchase-item:hover {
opacity: 0.85;
filter: grayscale(0.15);
}
.pin-icon {
display: inline-flex;
align-items: center;