mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-08 11:26:56 +08:00
feat: add API endpoint mapping and optimize danmaku handling
- Added API endpoint mapping functionality to support user-selected API endpoints in QueryAPIInternal - Refactored danmaku container component to use shallow refs for better performance - Implemented normalized danmaku list handling with unique ID generation - Added separate tracking of base and dynamic danmakus for better state management - Improved danmaku filtering and ranking logic with computed properties - Optimized processing state
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { APIRoot, PaginationResponse } from './api-models'
|
||||
import { apiFail } from '@/data/constants'
|
||||
import { apiFail, mapToCurrentAPI } from '@/data/constants'
|
||||
import { cookie } from './account'
|
||||
import { useBiliAuth } from '@/store/useBiliAuth'
|
||||
|
||||
@@ -76,7 +76,9 @@ async function QueryPostAPIWithParamsInternal<T>(
|
||||
}
|
||||
async function QueryAPIInternal<T>(url: URL, init: RequestInit) {
|
||||
try {
|
||||
const data = await fetch(url, init)
|
||||
// 使用用户选择的API
|
||||
const mappedUrl = mapToCurrentAPI(url.toString())
|
||||
const data = await fetch(mappedUrl, init)
|
||||
const result = (await data.json()) as T
|
||||
return result
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { DanmakuModel, ResponseLiveInfoModel } from '@/api/api-models'
|
||||
import { Info12Filled, Money20Regular, Money24Regular, Search24Filled, Wrench24Filled } from '@vicons/fluent'
|
||||
import { useDebounceFn, useLocalStorage, useWindowSize } from '@vueuse/core'
|
||||
import { useDebounceFn, useLocalStorage } from '@vueuse/core'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { List } from 'linqts'
|
||||
import {
|
||||
NAvatar,
|
||||
NButton,
|
||||
@@ -31,7 +30,7 @@ import {
|
||||
NTooltip,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { useAccount } from '@/api/account'
|
||||
import { EventDataTypes } from '@/api/api-models'
|
||||
import DanmakuItem from '@/components/DanmakuItem.vue'
|
||||
@@ -44,6 +43,7 @@ enum RankType {
|
||||
Danmaku,
|
||||
Paid,
|
||||
}
|
||||
|
||||
interface RankInfo {
|
||||
ouId: string
|
||||
uName: string
|
||||
@@ -60,7 +60,6 @@ const {
|
||||
itemHeight = 30,
|
||||
showLiveInfo = true,
|
||||
showLiver = false,
|
||||
// to = ClickNameTo.UserHistory,
|
||||
isInModal = false,
|
||||
showName = true,
|
||||
showBorder = true,
|
||||
@@ -79,56 +78,13 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const accountInfo = useAccount()
|
||||
const message = useMessage()
|
||||
|
||||
const debouncedFn = useDebounceFn(() => {
|
||||
UpdateDanmakus()
|
||||
}, 750)
|
||||
const isLoading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const showExportModal = ref(false)
|
||||
const userDanmakus = ref<DanmakuModel[] | undefined>()
|
||||
const hideAvatar = useLocalStorage('Setting.HideAvatar', false)
|
||||
const { width } = useWindowSize()
|
||||
|
||||
interface Props {
|
||||
currentLive: ResponseLiveInfoModel
|
||||
currentDanmakus: DanmakuModel[]
|
||||
defaultFilterSelected?: EventDataTypes[]
|
||||
height?: number
|
||||
itemHeight?: number
|
||||
showLiver?: boolean
|
||||
showLiveInfo?: boolean
|
||||
showName?: boolean
|
||||
// to?: ClickNameTo;
|
||||
isInModal?: boolean
|
||||
showRank?: boolean
|
||||
showBorder?: boolean
|
||||
showTools?: boolean
|
||||
showStatistic?: boolean
|
||||
animeNum?: boolean
|
||||
bordered?: boolean
|
||||
toolsVisiable?: boolean
|
||||
itemRange?: number
|
||||
to: 'userDanmakus' | 'space'
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
InsertDanmakus,
|
||||
})
|
||||
const danmakus = ref<DanmakuModel[]>([])
|
||||
watch(
|
||||
() => showTools,
|
||||
(newV, oldV) => {
|
||||
innerShowTools.value = newV
|
||||
},
|
||||
)
|
||||
const danmakuRef = computed(() => {
|
||||
// 不知道为啥不能直接watch
|
||||
return currentDanmakus
|
||||
})
|
||||
watch(danmakuRef, (newValue) => {
|
||||
danmakus.value = GetFilteredDanmakus(newValue)
|
||||
})
|
||||
|
||||
const keyword = ref('')
|
||||
const enableRegex = ref(false)
|
||||
@@ -141,25 +97,243 @@ const orderDecreasing = ref(false)
|
||||
const orderByPrice = ref(false)
|
||||
const processing = ref(false)
|
||||
const isRanking = ref(false)
|
||||
const rankInfo = ref<RankInfo[]>([])
|
||||
const currentRankInfo = ref<RankInfo[]>([])
|
||||
const rankType = ref(RankType.Danmaku)
|
||||
const hideEmoji = ref(false)
|
||||
const existEnterMessage = ref(false)
|
||||
|
||||
const exportType = ref<'json' | 'xml' | 'csv'>('json')
|
||||
const onlyExportFilteredDanmakus = ref(false)
|
||||
const isExporting = ref(false)
|
||||
const message = useMessage()
|
||||
|
||||
let processingTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const baseDanmakus = shallowRef<DanmakuModel[]>([])
|
||||
const dynamicDanmakus = shallowRef<DanmakuModel[]>([])
|
||||
|
||||
interface Props {
|
||||
currentLive: ResponseLiveInfoModel
|
||||
currentDanmakus: DanmakuModel[]
|
||||
defaultFilterSelected?: EventDataTypes[]
|
||||
height?: number
|
||||
itemHeight?: number
|
||||
showLiver?: boolean
|
||||
showLiveInfo?: boolean
|
||||
showName?: boolean
|
||||
isInModal?: boolean
|
||||
showRank?: boolean
|
||||
showBorder?: boolean
|
||||
showTools?: boolean
|
||||
showStatistic?: boolean
|
||||
animeNum?: boolean
|
||||
bordered?: boolean
|
||||
toolsVisiable?: boolean
|
||||
itemRange?: number
|
||||
to: 'userDanmakus' | 'space'
|
||||
}
|
||||
|
||||
function createDanmakuSignature(danmaku: DanmakuModel, index: number) {
|
||||
const segments = []
|
||||
if (danmaku.ouId) segments.push(`ou_${danmaku.ouId}`)
|
||||
if (danmaku.uId) segments.push(`u_${danmaku.uId}`)
|
||||
if (danmaku.time) segments.push(`t_${danmaku.time}`)
|
||||
if (segments.length === 0) segments.push(`idx_${index}`)
|
||||
return segments.join('_')
|
||||
}
|
||||
|
||||
function normalizeDanmakuList(source: DanmakuModel[] | undefined | null, existingIds?: Set<string>) {
|
||||
if (!source?.length) return []
|
||||
const usedIds = existingIds ?? new Set<string>()
|
||||
const normalized: DanmakuModel[] = []
|
||||
source.forEach((item, index) => {
|
||||
const baseId = item.id ?? createDanmakuSignature(item, index)
|
||||
let candidateId = baseId
|
||||
let suffix = 1
|
||||
while (usedIds.has(candidateId)) {
|
||||
candidateId = `${baseId}_${suffix++}`
|
||||
}
|
||||
normalized.push({
|
||||
...item,
|
||||
id: candidateId,
|
||||
})
|
||||
usedIds.add(candidateId)
|
||||
})
|
||||
return normalized
|
||||
}
|
||||
|
||||
function triggerProcessing(delay = 120) {
|
||||
processing.value = true
|
||||
if (processingTimer) {
|
||||
clearTimeout(processingTimer)
|
||||
}
|
||||
processingTimer = setTimeout(() => {
|
||||
processing.value = false
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function UpdateDanmakus() {
|
||||
triggerProcessing(80)
|
||||
}
|
||||
|
||||
const debouncedFn = useDebounceFn(UpdateDanmakus, 750)
|
||||
|
||||
watch(
|
||||
() => showTools,
|
||||
(value) => {
|
||||
innerShowTools.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => rankType.value,
|
||||
() => {
|
||||
triggerProcessing(40)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => currentDanmakus,
|
||||
(list) => {
|
||||
const normalized = normalizeDanmakuList(list)
|
||||
const baseIds = new Set(normalized.map(item => item.id))
|
||||
const filteredDynamic = dynamicDanmakus.value.filter(item => !baseIds.has(item.id))
|
||||
baseDanmakus.value = normalized
|
||||
if (filteredDynamic.length !== dynamicDanmakus.value.length) {
|
||||
dynamicDanmakus.value = filteredDynamic
|
||||
}
|
||||
triggerProcessing(normalized.length ? 80 : 0)
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const combinedDanmakus = computed(() => {
|
||||
if (!dynamicDanmakus.value.length) {
|
||||
return baseDanmakus.value
|
||||
}
|
||||
if (!baseDanmakus.value.length) {
|
||||
return dynamicDanmakus.value
|
||||
}
|
||||
return [...baseDanmakus.value, ...dynamicDanmakus.value]
|
||||
})
|
||||
|
||||
const existEnterMessage = computed(() =>
|
||||
combinedDanmakus.value.some(item => item.type === EventDataTypes.Enter),
|
||||
)
|
||||
|
||||
const filteredDanmakus = computed(() => {
|
||||
const source = combinedDanmakus.value
|
||||
if (!source.length) return []
|
||||
|
||||
const selectedTypes = new Set(filterSelected.value)
|
||||
let working = source.filter(item => selectedTypes.has(item.type))
|
||||
|
||||
if (hideEmoji.value) {
|
||||
working = working.filter(item => item.type !== EventDataTypes.Message || !item.isEmoji)
|
||||
}
|
||||
|
||||
const keywordValue = keyword.value.trim()
|
||||
if (keywordValue !== '') {
|
||||
let regex: RegExp | null = null
|
||||
if (enableRegex.value) {
|
||||
try {
|
||||
regex = new RegExp(keywordValue)
|
||||
} catch {
|
||||
regex = null
|
||||
}
|
||||
}
|
||||
const keywordLower = keywordValue.toLowerCase()
|
||||
const matcher = (item: DanmakuModel) => {
|
||||
if (item.uId != null && item.uId.toString() === keywordValue) return true
|
||||
if (item.uName && item.uName === keywordValue) return true
|
||||
const message = item.msg ?? ''
|
||||
if (!message) return false
|
||||
if (regex) {
|
||||
return regex.test(message)
|
||||
}
|
||||
return message.toLowerCase().includes(keywordLower)
|
||||
}
|
||||
working = working.filter(item => (deselect.value ? !matcher(item) : matcher(item)))
|
||||
}
|
||||
|
||||
if (price.value && price.value > 0) {
|
||||
const minPrice = price.value
|
||||
working = working.filter(item => (item.price ?? 0) >= (minPrice ?? 0))
|
||||
}
|
||||
|
||||
if (orderByPrice.value) {
|
||||
working = working
|
||||
.filter(item => item.type !== EventDataTypes.Message)
|
||||
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0))
|
||||
} else {
|
||||
working = [...working].sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
if (orderDecreasing.value) {
|
||||
working.reverse()
|
||||
}
|
||||
|
||||
return working
|
||||
})
|
||||
|
||||
const filteredDanmakuCount = computed(() => filteredDanmakus.value.length)
|
||||
const totalDanmakuCount = computed(() => combinedDanmakus.value.length)
|
||||
const totalFilteredPrice = computed(() =>
|
||||
filteredDanmakus.value.reduce((sum, item) => sum + (item.price && item.price > 0 ? item.price : 0), 0),
|
||||
)
|
||||
|
||||
const rankStats = computed(() => aggregateRankStats(combinedDanmakus.value))
|
||||
const currentRankInfo = computed(() => buildRankedList(rankStats.value, rankType.value))
|
||||
|
||||
function aggregateRankStats(source: DanmakuModel[]): RankInfo[] {
|
||||
if (!source.length) return []
|
||||
const ranking = new Map<string, RankInfo>()
|
||||
source.forEach((item, index) => {
|
||||
const key = item.ouId || (item.uId != null ? `uid_${item.uId}` : `idx_${index}`)
|
||||
let info = ranking.get(key)
|
||||
if (!info) {
|
||||
info = {
|
||||
ouId: item.ouId || key,
|
||||
uName: item.uName ?? '未命名用户',
|
||||
Paid: 0,
|
||||
Danmakus: 0,
|
||||
Index: 0,
|
||||
}
|
||||
ranking.set(key, info)
|
||||
} else if (item.uName) {
|
||||
info.uName = item.uName
|
||||
}
|
||||
if (item.type === EventDataTypes.Message) {
|
||||
info.Danmakus += 1
|
||||
}
|
||||
if (typeof item.price === 'number') {
|
||||
info.Paid += item.price
|
||||
}
|
||||
})
|
||||
return Array.from(ranking.values())
|
||||
}
|
||||
|
||||
function buildRankedList(stats: RankInfo[], type: RankType) {
|
||||
if (!stats.length) return []
|
||||
const filtered = stats
|
||||
.filter(info => (type === RankType.Paid ? info.Paid > 0 : true))
|
||||
.map(info => ({ ...info }))
|
||||
filtered.sort((a, b) =>
|
||||
type === RankType.Danmaku ? b.Danmakus - a.Danmakus : b.Paid - a.Paid,
|
||||
)
|
||||
const limited = filtered.slice(0, 100)
|
||||
limited.forEach((info, idx) => {
|
||||
info.Index = idx + 1
|
||||
})
|
||||
return limited
|
||||
}
|
||||
|
||||
function OnNameClick(uId: number, ouId: string) {
|
||||
if (isInModal) {
|
||||
emit('onClickName', uId, ouId)
|
||||
return
|
||||
}
|
||||
const sourceDanmakus = combinedDanmakus.value
|
||||
switch (to) {
|
||||
case 'userDanmakus': {
|
||||
userDanmakus.value = currentDanmakus.filter(d => (d.uId ? d.uId == uId : d.ouId == ouId))
|
||||
userDanmakus.value = sourceDanmakus.filter(d => (d.uId ? d.uId === uId : d.ouId === ouId))
|
||||
showModal.value = true
|
||||
break
|
||||
}
|
||||
@@ -176,6 +350,7 @@ function OnNameClick(uId: number, ouId: string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ToUserSpace(uId: number) {
|
||||
showModal.value = false
|
||||
nextTick(() => {
|
||||
@@ -187,71 +362,56 @@ function ToUserSpace(uId: number) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function ChangePrice(p: number) {
|
||||
if (p == price.value) {
|
||||
if (p === price.value) {
|
||||
price.value = undefined
|
||||
} else {
|
||||
price.value = p
|
||||
}
|
||||
UpdateDanmakus()
|
||||
}
|
||||
function UpdateDanmakus() {
|
||||
processing.value = true
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
canInsert = false
|
||||
danmakus.value = GetFilteredDanmakus()
|
||||
setTimeout(() => {
|
||||
canInsert = true
|
||||
}, 1000) // 立马就能的话会莫名key重复
|
||||
processing.value = false
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
|
||||
function OnRank(isRank: boolean) {
|
||||
if (isRank) OnRankDirect(rankType.value, rankInfo.value.length == 0 && danmakus.value.length > 0)
|
||||
}
|
||||
function OnRankDirect(type: RankType, refresh: boolean, orderByDescending = true) {
|
||||
if (refresh) {
|
||||
const rank = {} as {
|
||||
[ouId: string]: RankInfo
|
||||
}
|
||||
currentDanmakus.forEach((danmaku) => {
|
||||
if (danmaku.ouId in rank) {
|
||||
if (danmaku.type == EventDataTypes.Message) rank[danmaku.ouId].Danmakus++
|
||||
rank[danmaku.ouId].Paid += danmaku.price ?? 0
|
||||
} else {
|
||||
rank[danmaku.ouId] = {
|
||||
// uId: danmaku.uId,
|
||||
ouId: danmaku.ouId,
|
||||
uName: danmaku.uName,
|
||||
Paid: danmaku.price ?? 0,
|
||||
Danmakus: danmaku.type == EventDataTypes.Message ? 1 : 0,
|
||||
Index: 0,
|
||||
}
|
||||
}
|
||||
})
|
||||
rankInfo.value = new List(Object.entries(rank)).Select(([uId, user]) => user).ToArray()
|
||||
if (isRank) {
|
||||
triggerProcessing(60)
|
||||
}
|
||||
let ienum = {} as List<RankInfo>
|
||||
switch (rankType.value) {
|
||||
case RankType.Danmaku: {
|
||||
ienum = new List(rankInfo.value).OrderByDescending(user => user.Danmakus)
|
||||
break
|
||||
}
|
||||
case RankType.Paid: {
|
||||
ienum = new List(rankInfo.value).Where(user => (user?.Paid ?? 0) > 0).OrderByDescending(user => user.Paid)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!orderByDescending) ienum = ienum.Reverse()
|
||||
currentRankInfo.value = ienum.Take(100).ToArray()
|
||||
let index = 1
|
||||
currentRankInfo.value.forEach((info) => {
|
||||
info.Index = index
|
||||
index++
|
||||
})
|
||||
}
|
||||
|
||||
function InsertDanmakus(targetDanmakus: DanmakuModel[]) {
|
||||
if (!Array.isArray(targetDanmakus) || targetDanmakus.length === 0) return
|
||||
const existingIds = new Set<string>([
|
||||
...baseDanmakus.value.map(item => item.id),
|
||||
...dynamicDanmakus.value.map(item => item.id),
|
||||
])
|
||||
const normalized = normalizeDanmakuList(targetDanmakus, existingIds)
|
||||
if (!normalized.length) return
|
||||
dynamicDanmakus.value = orderDecreasing.value
|
||||
? [...normalized, ...dynamicDanmakus.value]
|
||||
: [...dynamicDanmakus.value, ...normalized]
|
||||
triggerProcessing(40)
|
||||
}
|
||||
|
||||
function Export() {
|
||||
isExporting.value = true
|
||||
const source = onlyExportFilteredDanmakus.value ? filteredDanmakus.value : combinedDanmakus.value
|
||||
saveAs(
|
||||
new Blob(
|
||||
[
|
||||
GetString(
|
||||
accountInfo.value,
|
||||
currentLive,
|
||||
source,
|
||||
exportType.value,
|
||||
),
|
||||
],
|
||||
{ type: 'text/plain;charset=utf-8' },
|
||||
),
|
||||
`${Date.now()}_${currentLive.startAt}_${currentLive.title.replace('_', '-')}_${accountInfo.value?.name}.${exportType.value}`,
|
||||
)
|
||||
isExporting.value = false
|
||||
}
|
||||
|
||||
function GetRankIndexColor(index: number) {
|
||||
switch (index) {
|
||||
case 1:
|
||||
@@ -264,86 +424,22 @@ function GetRankIndexColor(index: number) {
|
||||
return 'background:#afafaf;'
|
||||
}
|
||||
}
|
||||
function Export() {
|
||||
isExporting.value = true
|
||||
saveAs(
|
||||
new Blob(
|
||||
[
|
||||
GetString(
|
||||
accountInfo.value,
|
||||
currentLive,
|
||||
onlyExportFilteredDanmakus.value ? danmakus.value : currentDanmakus,
|
||||
exportType.value,
|
||||
),
|
||||
],
|
||||
{ type: 'text/plain;charset=utf-8' },
|
||||
),
|
||||
`${Date.now()}_${currentLive.startAt}_${currentLive.title.replace('_', '-')}_${accountInfo.value?.name}.${exportType.value}`,
|
||||
)
|
||||
isExporting.value = false
|
||||
}
|
||||
let canInsert = false
|
||||
function InsertDanmakus(targetDanmakus: DanmakuModel[]) {
|
||||
if (processing.value || !canInsert) {
|
||||
return
|
||||
}
|
||||
const data = GetFilteredDanmakus(targetDanmakus)
|
||||
if (orderDecreasing.value) {
|
||||
danmakus.value.unshift(...data)
|
||||
currentDanmakus.unshift(...data)
|
||||
} else {
|
||||
danmakus.value.push(...data)
|
||||
currentDanmakus.push(...data)
|
||||
}
|
||||
}
|
||||
function GetFilteredDanmakus(targetDanmakus?: DanmakuModel[]) {
|
||||
if (!targetDanmakus) targetDanmakus = currentDanmakus
|
||||
let tempDanmakus = targetDanmakus.filter(d => filterSelected.value.includes(d.type))
|
||||
if (hideEmoji.value) {
|
||||
tempDanmakus = tempDanmakus.filter(d => d.type != EventDataTypes.Message || !d.isEmoji)
|
||||
}
|
||||
if (orderByPrice.value) {
|
||||
tempDanmakus = tempDanmakus
|
||||
.filter(d => d.type != EventDataTypes.Message)
|
||||
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0))
|
||||
} else {
|
||||
tempDanmakus = tempDanmakus.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
if (keyword.value && keyword.value != '') {
|
||||
tempDanmakus = tempDanmakus.filter(d => (deselect.value ? !CheckKeyword(d) : CheckKeyword(d)))
|
||||
}
|
||||
function CheckKeyword(d: DanmakuModel) {
|
||||
if (d.uId.toString() == keyword.value || d.uName == keyword.value) {
|
||||
return true
|
||||
}
|
||||
return enableRegex.value
|
||||
? !!d.msg?.match(keyword.value)
|
||||
: d.msg?.toLowerCase().includes(keyword.value.toLowerCase()) == true
|
||||
}
|
||||
if (price.value && price.value > 0) {
|
||||
tempDanmakus = tempDanmakus.filter(d => (d.price ?? 0) >= (price.value ?? 0))
|
||||
}
|
||||
if (orderDecreasing.value) tempDanmakus = tempDanmakus.reverse()
|
||||
let index = 0
|
||||
tempDanmakus.forEach((d) => {
|
||||
d.id = `${d.ouId}_${d.time}_${index}`
|
||||
index++
|
||||
})
|
||||
return tempDanmakus
|
||||
}
|
||||
function RoundNumber(num: number) {
|
||||
if (Number.isInteger(num)) {
|
||||
return num
|
||||
} else {
|
||||
return num.toFixed(1)
|
||||
}
|
||||
return num.toFixed(1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
danmakus.value = GetFilteredDanmakus()
|
||||
existEnterMessage.value = danmakus.value.some(d => d.type == EventDataTypes.Message)
|
||||
// defaultFilterSelected.push(EventDataTypes.Enter)
|
||||
onBeforeUnmount(() => {
|
||||
if (processingTimer) {
|
||||
clearTimeout(processingTimer)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
InsertDanmakus,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -681,8 +777,8 @@ onMounted(() => {
|
||||
style="font-size: 12px"
|
||||
size="small"
|
||||
>
|
||||
{{ danmakus.length }}
|
||||
{{ danmakus.length != currentDanmakus.length ? `/ ${currentDanmakus.length}` : '' }}
|
||||
{{ filteredDanmakuCount }}
|
||||
{{ filteredDanmakuCount !== totalDanmakuCount ? `/ ${totalDanmakuCount}` : '' }}
|
||||
条
|
||||
</NTag>
|
||||
<NDivider vertical />
|
||||
@@ -693,14 +789,14 @@ onMounted(() => {
|
||||
:bordered="false"
|
||||
>
|
||||
💰
|
||||
{{ RoundNumber(danmakus.reduce((a, b) => a + (b.price && b.price > 0 ? b.price : 0), 0)) }}
|
||||
{{ RoundNumber(totalFilteredPrice) }}
|
||||
</NTag>
|
||||
</NDivider>
|
||||
</NCollapseTransition>
|
||||
<div :style="isRanking ? 'display:none' : ''">
|
||||
<SimpleVirtualList
|
||||
v-if="danmakus.length > itemRange"
|
||||
:items="danmakus"
|
||||
v-if="filteredDanmakuCount > itemRange"
|
||||
:items="filteredDanmakus"
|
||||
:default-size="itemHeight"
|
||||
:default-height="height ?? 1000"
|
||||
>
|
||||
@@ -718,7 +814,7 @@ onMounted(() => {
|
||||
</template>
|
||||
</SimpleVirtualList>
|
||||
<p
|
||||
v-for="item in danmakus"
|
||||
v-for="item in filteredDanmakus"
|
||||
v-else
|
||||
:key="item.id"
|
||||
:style="`min-height: ${itemHeight}px;width:97%;display:flex;align-items:center;`"
|
||||
@@ -737,11 +833,6 @@ onMounted(() => {
|
||||
<NRadioGroup
|
||||
v-model:value="rankType"
|
||||
size="small"
|
||||
@update:value="
|
||||
(type) => {
|
||||
OnRankDirect(type, false)
|
||||
}
|
||||
"
|
||||
>
|
||||
<NRadioButton :value="RankType.Danmaku">
|
||||
弹幕
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineAsyncComponent, markRaw, ref } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const debugAPI
|
||||
= import.meta.env.VITE_API == 'dev'
|
||||
@@ -18,6 +19,42 @@ export const IMGUR_URL = `${FILE_BASE_URL}/imgur/`
|
||||
export const THINGS_URL = `${FILE_BASE_URL}/things/`
|
||||
export const apiFail = ref(false)
|
||||
|
||||
// API 配置
|
||||
export interface APIConfig {
|
||||
name: string
|
||||
url: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export const availableAPIs: APIConfig[] = [
|
||||
{ name: '主API (国内)', url: releseAPI, key: 'main' },
|
||||
{ name: '备用API (国外)', url: failoverAPI, key: 'failover' },
|
||||
]
|
||||
|
||||
// 从 localStorage 读取用户选择的 API,默认使用主 API
|
||||
export const selectedAPIKey = useStorage<string>('Settings.SelectedAPI', 'main')
|
||||
|
||||
// 获取当前选择的 API URL
|
||||
export function getCurrentAPIUrl(): string {
|
||||
if (import.meta.env.NODE_ENV === 'development') {
|
||||
return debugAPI
|
||||
}
|
||||
const selected = availableAPIs.find(api => api.key === selectedAPIKey.value)
|
||||
return selected?.url || releseAPI
|
||||
}
|
||||
|
||||
// 将URL映射到当前选择的API
|
||||
export function mapToCurrentAPI(url: string): string {
|
||||
if (import.meta.env.NODE_ENV === 'development') {
|
||||
return url // 开发环境不替换
|
||||
}
|
||||
const currentAPI = getCurrentAPIUrl()
|
||||
// 替换所有已知的API域名为当前选择的API
|
||||
return url
|
||||
.replace(releseAPI, currentAPI)
|
||||
.replace(failoverAPI, currentAPI)
|
||||
}
|
||||
|
||||
export const BASE_URL
|
||||
= import.meta.env.NODE_ENV === 'development'
|
||||
? debugAPI
|
||||
|
||||
@@ -34,16 +34,18 @@ import {
|
||||
NPageHeader,
|
||||
NPopconfirm,
|
||||
NScrollbar,
|
||||
NSelect,
|
||||
NSlider,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NText,
|
||||
NTime,
|
||||
NTooltip,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { computed, h, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
// @ts-ignore
|
||||
import APlayer from 'vue3-aplayer'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
@@ -51,7 +53,7 @@ import { cookie, isLoadingAccount, useAccount } from '@/api/account'
|
||||
import { ThemeType } from '@/api/api-models'
|
||||
import { QueryGetAPI } from '@/api/query'
|
||||
import RegisterAndLogin from '@/components/RegisterAndLogin.vue'
|
||||
import { ACCOUNT_API_URL } from '@/data/constants'
|
||||
import { ACCOUNT_API_URL, availableAPIs, selectedAPIKey } from '@/data/constants'
|
||||
import { useBiliAuth } from '@/store/useBiliAuth'
|
||||
import { useMusicRequestProvider } from '@/store/useMusicRequest'
|
||||
import { isDarkMode, NavigateToNewTab } from '@/Utils'
|
||||
@@ -180,6 +182,45 @@ const currentPlayingInfo = computed(() => {
|
||||
const canResendEmail = ref(false)
|
||||
const isBiliVerified = computed(() => accountInfo.value?.isBiliVerified)
|
||||
|
||||
// 加载超时检测
|
||||
const loadingTimeout = ref(false)
|
||||
const showAPISwitchDialog = ref(false)
|
||||
let loadingTimer: number | null = null
|
||||
|
||||
// 监听加载状态,设置3秒超时
|
||||
watch(isLoadingAccount, (loading) => {
|
||||
if (loading) {
|
||||
loadingTimeout.value = false
|
||||
showAPISwitchDialog.value = false
|
||||
loadingTimer = window.setTimeout(() => {
|
||||
if (isLoadingAccount.value) {
|
||||
loadingTimeout.value = true
|
||||
// 如果当前使用主API,提示切换到备用API
|
||||
if (selectedAPIKey.value === 'main') {
|
||||
showAPISwitchDialog.value = true
|
||||
}
|
||||
}
|
||||
}, 3000)
|
||||
} else {
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer)
|
||||
loadingTimer = null
|
||||
}
|
||||
loadingTimeout.value = false
|
||||
showAPISwitchDialog.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 切换API
|
||||
function switchToBackupAPI() {
|
||||
selectedAPIKey.value = 'failover'
|
||||
message.info('已切换到备用API,正在重新加载...')
|
||||
showAPISwitchDialog.value = false
|
||||
setTimeout(() => {
|
||||
location.reload()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 图标渲染函数 - 用于菜单项
|
||||
const renderIcon = (icon: any) => () => h(NIcon, null, { default: () => h(icon) })
|
||||
|
||||
@@ -615,6 +656,13 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer)
|
||||
loadingTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1124,6 +1172,24 @@ onMounted(() => {
|
||||
<NSpin :loading="isLoadingAccount" size="large">
|
||||
<NText>正在请求账户数据...</NText>
|
||||
</NSpin>
|
||||
<NAlert
|
||||
v-if="showAPISwitchDialog"
|
||||
type="warning"
|
||||
style="margin-top: 20px; max-width: 400px;"
|
||||
title="加载时间较长"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NText>当前API响应较慢,是否切换到备用API?</NText>
|
||||
<NFlex justify="end" :size="8">
|
||||
<NButton size="small" @click="showAPISwitchDialog = false">
|
||||
继续等待
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="switchToBackupAPI">
|
||||
切换到备用API
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NSpace>
|
||||
</NAlert>
|
||||
</NFlex>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
NInputGroup,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
@@ -31,7 +32,7 @@ import { cookie, useAccount } from '@/api/account'
|
||||
import { BiliAuthCodeStatusType } from '@/api/api-models'
|
||||
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
|
||||
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
|
||||
import { ACCOUNT_API_URL, CN_HOST, TURNSTILE_KEY } from '@/data/constants'
|
||||
import { ACCOUNT_API_URL, availableAPIs, CN_HOST, selectedAPIKey, TURNSTILE_KEY } from '@/data/constants'
|
||||
import { checkUpdateNote } from '@/data/UpdateNote'
|
||||
import SettingPaymentView from './Setting_PaymentView.vue'
|
||||
import SettingsManageView from './SettingsManageView.vue'
|
||||
@@ -63,6 +64,20 @@ const biliCode = ref('')
|
||||
const biliAuthText = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
// API选择器选项
|
||||
const apiOptions = availableAPIs.map(api => ({
|
||||
label: api.name,
|
||||
value: api.key,
|
||||
}))
|
||||
|
||||
// 切换API
|
||||
function handleAPIChange(value: string) {
|
||||
message.info(`正在切换到${availableAPIs.find(api => api.key === value)?.name}...`)
|
||||
setTimeout(() => {
|
||||
location.reload()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
cookie.value = undefined
|
||||
window.location.reload()
|
||||
@@ -546,6 +561,26 @@ onUnmounted(() => {
|
||||
</NAlert>
|
||||
</NSpace>
|
||||
<NDivider />
|
||||
|
||||
<NAlert
|
||||
type="info"
|
||||
title="API 设置"
|
||||
style="width: 100%; max-width: 800px; margin-bottom: 16px;"
|
||||
>
|
||||
<NFlex align="center" :wrap="false" style="gap: 12px;">
|
||||
<NText>当前使用的API:</NText>
|
||||
<NSelect
|
||||
v-model:value="selectedAPIKey"
|
||||
:options="apiOptions"
|
||||
style="flex: 1; max-width: 200px;"
|
||||
@update:value="handleAPIChange"
|
||||
/>
|
||||
<NText depth="3" style="font-size: 12px;">
|
||||
如果访问速度较慢可以尝试切换API
|
||||
</NText>
|
||||
</NFlex>
|
||||
</NAlert>
|
||||
<NDivider />
|
||||
<NSpace justify="center">
|
||||
<NButton
|
||||
type="info"
|
||||
|
||||
@@ -59,32 +59,12 @@ async function loadInitialData() {
|
||||
}
|
||||
}
|
||||
|
||||
function eventModelToDanmakuModel(event: EventModel): DanmakuModel {
|
||||
return {
|
||||
ouId: event.ouid,
|
||||
uId: event.uid ?? 0,
|
||||
uName: event.uname,
|
||||
msg: event.msg ?? '',
|
||||
time: event.time,
|
||||
type: event.type,
|
||||
price: event.price,
|
||||
num: event.num,
|
||||
guardLevel: event.guard_level,
|
||||
fansMedalLevel: event.fans_medal_level,
|
||||
fansMedalName: event.fans_medal_name,
|
||||
fansMedalWearingStatus: event.fans_medal_wearing_status,
|
||||
isEmoji: !!event.emoji,
|
||||
emoji: event.emoji,
|
||||
id: `${event.ouid}_${event.time}_${Date.now()}`,
|
||||
} as DanmakuModel
|
||||
}
|
||||
|
||||
function onNewDanmaku(event: EventModel) {
|
||||
function onNewDanmaku(event: DanmakuModel) {
|
||||
if (!liveInfo.value) return
|
||||
|
||||
const danmaku = eventModelToDanmakuModel(event)
|
||||
danmakuContainerRef.value?.InsertDanmakus([danmaku])
|
||||
|
||||
console.log('New Danmaku:', event)
|
||||
|
||||
danmakuContainerRef.value?.InsertDanmakus([event])
|
||||
|
||||
// 更新统计信息
|
||||
if (event.price && event.price > 0) {
|
||||
liveInfo.value.live.totalIncome += event.price
|
||||
|
||||
Reference in New Issue
Block a user