mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
Compare commits
2 Commits
4ad9766043
...
4b4fb8d87e
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4fb8d87e | |||
|
|
902926f28f |
@@ -72,6 +72,7 @@
|
|||||||
"vue-echarts": "^8.0.0",
|
"vue-echarts": "^8.0.0",
|
||||||
"vue-request": "^2.0.4",
|
"vue-request": "^2.0.4",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
|
"vue-toastification": "^1.7.14",
|
||||||
"vue-turnstile": "^1.0.11",
|
"vue-turnstile": "^1.0.11",
|
||||||
"vue3-aplayer": "^1.7.3",
|
"vue3-aplayer": "^1.7.3",
|
||||||
"vue3-marquee": "^4.2.2",
|
"vue3-marquee": "^4.2.2",
|
||||||
|
|||||||
3
src/components.d.ts
vendored
3
src/components.d.ts
vendored
@@ -18,8 +18,6 @@ declare module 'vue' {
|
|||||||
LabelItem: typeof import('./components/LabelItem.vue')['default']
|
LabelItem: typeof import('./components/LabelItem.vue')['default']
|
||||||
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||||
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||||
NAlert: typeof import('naive-ui')['NAlert']
|
|
||||||
NBadge: typeof import('naive-ui')['NBadge']
|
|
||||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
NFlex: typeof import('naive-ui')['NFlex']
|
NFlex: typeof import('naive-ui')['NFlex']
|
||||||
@@ -29,7 +27,6 @@ declare module 'vue' {
|
|||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
NTag: typeof import('naive-ui')['NTag']
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
NText: typeof import('naive-ui')['NText']
|
NText: typeof import('naive-ui')['NText']
|
||||||
NTime: typeof import('naive-ui')['NTime']
|
|
||||||
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
||||||
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
|
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
|
||||||
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
|
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ function createColumns(): DataTableColumns<SongsInfo> {
|
|||||||
resizable: true,
|
resizable: true,
|
||||||
minWidth: 150, // 增加最小宽度
|
minWidth: 150, // 增加最小宽度
|
||||||
width: 300,
|
width: 300,
|
||||||
sorter: 'default', // 启用默认排序
|
sorter: true, // 启用默认排序
|
||||||
render(data) {
|
render(data) {
|
||||||
// 同时显示原名和翻译名 (如果存在)
|
// 同时显示原名和翻译名 (如果存在)
|
||||||
return h(NSpace, { vertical: true, size: 0, wrap: false }, () => [
|
return h(NSpace, { vertical: true, size: 0, wrap: false }, () => [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSpin, useLoadingBar, useMessage, useModal } from 'naive-ui'
|
import { NSpin, useDialog, useLoadingBar, useMessage, useModal, useNotification } from 'naive-ui'
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { cookie, useAccount } from '@/api/account'
|
import { cookie, useAccount } from '@/api/account'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
DanmakuUserInfo,
|
DanmakuUserInfo,
|
||||||
EventModel,
|
EventModel,
|
||||||
|
Setting_LiveRequest,
|
||||||
SongRequestInfo,
|
SongRequestInfo,
|
||||||
SongsInfo,
|
SongsInfo,
|
||||||
} from '@/api/api-models'
|
} from '@/api/api-models'
|
||||||
@@ -69,7 +70,7 @@ export const useLiveRequest = defineStore('songRequest', () => {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const settings = accountInfo.value?.settings?.songRequest || {}
|
const settings: Setting_LiveRequest = accountInfo.value?.settings?.songRequest
|
||||||
|
|
||||||
switch (settings.sortType) {
|
switch (settings.sortType) {
|
||||||
case QueueSortType.TimeFirst: {
|
case QueueSortType.TimeFirst: {
|
||||||
@@ -154,7 +155,7 @@ export const useLiveRequest = defineStore('songRequest', () => {
|
|||||||
`[SONG-REQUEST] 收到 [${danmaku.uname}] 的点播${danmaku.type == EventDataTypes.SC ? 'SC' : '弹幕'}: ${danmaku.msg}`,
|
`[SONG-REQUEST] 收到 [${danmaku.uname}] 的点播${danmaku.type == EventDataTypes.SC ? 'SC' : '弹幕'}: ${danmaku.msg}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const settings = accountInfo.value?.settings?.songRequest || {}
|
const settings: Setting_LiveRequest = accountInfo.value?.settings?.songRequest
|
||||||
|
|
||||||
if (settings.enableOnStreaming && accountInfo.value?.streamerInfo?.isStreaming != true) {
|
if (settings.enableOnStreaming && accountInfo.value?.streamerInfo?.isStreaming != true) {
|
||||||
window.$notification.info({
|
window.$notification.info({
|
||||||
@@ -436,7 +437,7 @@ export const useLiveRequest = defineStore('songRequest', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onGetSC(danmaku: EventModel) {
|
function onGetSC(danmaku: EventModel) {
|
||||||
const settings = accountInfo.value?.settings?.songRequest || {}
|
const settings = accountInfo.value?.settings?.songRequest
|
||||||
|
|
||||||
if (settings.allowSC && checkMessage(danmaku.msg)) {
|
if (settings.allowSC && checkMessage(danmaku.msg)) {
|
||||||
addSong(danmaku)
|
addSong(danmaku)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export abstract class BaseRTCClient {
|
|||||||
|
|
||||||
this.send('VTsuru.RTCEvent.On', eventName)
|
this.send('VTsuru.RTCEvent.On', eventName)
|
||||||
}
|
}
|
||||||
|
|
||||||
public off(eventName: string, listener: (args: any) => void) {
|
public off(eventName: string, listener: (args: any) => void) {
|
||||||
if (this.events[eventName]) {
|
if (this.events[eventName]) {
|
||||||
const index = this.events[eventName].indexOf(listener)
|
const index = this.events[eventName].indexOf(listener)
|
||||||
@@ -99,14 +100,14 @@ export class SlaveRTCClient extends BaseRTCClient {
|
|||||||
type: 'slave' = 'slave' as const
|
type: 'slave' = 'slave' as const
|
||||||
|
|
||||||
protected async getAllRTC(): Promise<ComponentsEventHubModel[]> {
|
protected async getAllRTC(): Promise<ComponentsEventHubModel[]> {
|
||||||
return await this.vhub.invoke<ComponentsEventHubModel[]>('GetAllRTC') || []
|
return await this.vhub.invoke<ComponentsEventHubModel[]>('GetOnlineRTC') || []
|
||||||
}
|
}
|
||||||
|
|
||||||
public async connectToAllMaster() {
|
public async connectToAllMaster() {
|
||||||
const masters = (await this.getAllRTC()).filter(
|
const masters = (await this.getAllRTC()).filter(
|
||||||
(item: ComponentsEventHubModel) =>
|
(item: ComponentsEventHubModel) =>
|
||||||
item.IsMaster
|
item.IsMaster
|
||||||
&& item.Token != this.peer!.id
|
&& item.Token != this.peer.id
|
||||||
&& !this.connections.some(conn => conn.peer == item.Token),
|
&& !this.connections.some(conn => conn.peer == item.Token),
|
||||||
)
|
)
|
||||||
masters.forEach((item: ComponentsEventHubModel) => {
|
masters.forEach((item: ComponentsEventHubModel) => {
|
||||||
|
|||||||
122
src/store/useOBSNotification.ts
Normal file
122
src/store/useOBSNotification.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { acceptHMRUpdate, defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useVTsuruHub } from './useVTsuruHub'
|
||||||
|
|
||||||
|
interface ObsNotificationPayload {
|
||||||
|
Type: 'success' | 'failed'
|
||||||
|
Title?: string
|
||||||
|
Message: string
|
||||||
|
Source?: string | null
|
||||||
|
UserName?: string | null
|
||||||
|
Timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabelMap: Record<string, string> = {
|
||||||
|
'live-request': '点歌',
|
||||||
|
'queue': '排队',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOBSNotification = defineStore('obs-notification', () => {
|
||||||
|
const isInited = ref(false)
|
||||||
|
const hub = useVTsuruHub()
|
||||||
|
const listeners = new Map<string, (payload: ObsNotificationPayload) => void>()
|
||||||
|
|
||||||
|
function resolveMeta(payload: ObsNotificationPayload): string | undefined {
|
||||||
|
const userName = payload.UserName?.trim()
|
||||||
|
const sourceLabel = payload.Source ? sourceLabelMap[payload.Source] ?? payload.Source : undefined
|
||||||
|
|
||||||
|
if (!userName && !sourceLabel) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [userName, sourceLabel].filter(Boolean).join(' · ')
|
||||||
|
return text || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTitle(payload: ObsNotificationPayload): string {
|
||||||
|
if (payload.Title?.trim()) {
|
||||||
|
return payload.Title
|
||||||
|
}
|
||||||
|
if (payload.Source && sourceLabelMap[payload.Source]) {
|
||||||
|
return sourceLabelMap[payload.Source]
|
||||||
|
}
|
||||||
|
return payload.Type === 'success' ? '提示' : '警告'
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(payload: ObsNotificationPayload) {
|
||||||
|
const notification = window.$notification
|
||||||
|
if (!notification) {
|
||||||
|
console.warn('[OBS] notification instance missing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('[OBS] 收到通知', payload)
|
||||||
|
|
||||||
|
const method = payload.Type === 'success' ? 'success' : 'error'
|
||||||
|
const title = resolveTitle(payload)
|
||||||
|
const description = payload.Message || '未知通知'
|
||||||
|
const meta = resolveMeta(payload)
|
||||||
|
|
||||||
|
if (typeof notification[method] === 'function') {
|
||||||
|
notification[method]({
|
||||||
|
title: payload.Type === 'success' ? '成功' : `失败`,
|
||||||
|
description,
|
||||||
|
duration: method === 'error' ? 8000 : 5000,
|
||||||
|
keepAliveOnHover: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
notification.create({
|
||||||
|
title: payload.Type === 'success' ? '成功' : `失败`,
|
||||||
|
content: description,
|
||||||
|
duration: method === 'error' ? 8000 : 5000,
|
||||||
|
keepAliveOnHover: true,
|
||||||
|
type: method,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 OBS 通知系统
|
||||||
|
* @param sources 可选的source过滤列表,如果提供则只显示这些类型的通知
|
||||||
|
*/
|
||||||
|
async function init(sources?: string[]) {
|
||||||
|
const listenerId = sources ? sources.sort().join(',') : 'all'
|
||||||
|
|
||||||
|
// 如果已经为这个过滤器初始化过,直接返回
|
||||||
|
if (listeners.has(listenerId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = (payload: ObsNotificationPayload) => {
|
||||||
|
// 如果指定了sources过滤器,检查通知是否匹配
|
||||||
|
|
||||||
|
if (sources && sources.length > 0) {
|
||||||
|
if (!payload.Source || !sources.includes(payload.Source)) {
|
||||||
|
return // 不匹配,忽略此通知
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showNotification(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners.set(listenerId, listener)
|
||||||
|
|
||||||
|
if (!isInited.value) {
|
||||||
|
isInited.value = true
|
||||||
|
await hub.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
await hub.on('ObsNotification', listener)
|
||||||
|
|
||||||
|
const filterInfo = sources && sources.length > 0
|
||||||
|
? `(过滤: ${sources.join(', ')})`
|
||||||
|
: '(所有类型)'
|
||||||
|
console.log(`[OBS] OBS 通知模块已初始化 ${filterInfo}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(acceptHMRUpdate(useOBSNotification, import.meta.hot))
|
||||||
|
}
|
||||||
@@ -98,9 +98,9 @@ export const useVTsuruHub = defineStore('VTsuruHub', () => {
|
|||||||
signalRClient.value?.onreconnected(listener)
|
signalRClient.value?.onreconnected(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Init() {
|
async function Init() {
|
||||||
if (!isInited.value && !isIniting.value) {
|
if (!isInited.value && !isIniting.value) {
|
||||||
connectSignalR()
|
await connectSignalR()
|
||||||
}
|
}
|
||||||
return useVTsuruHub()
|
return useVTsuruHub()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,14 @@ import { NSpin } from 'naive-ui'
|
|||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useAccount } from '@/api/account'
|
import { useAccount } from '@/api/account'
|
||||||
import { useWebFetcher } from '@/store/useWebFetcher'
|
import { useWebFetcher } from '@/store/useWebFetcher'
|
||||||
|
import { useOBSNotification } from '@/store/useOBSNotification'
|
||||||
|
|
||||||
const timer = ref<any>()
|
const timer = ref<any>()
|
||||||
const visible = ref(true)
|
const visible = ref(true)
|
||||||
const active = ref(true)
|
const active = ref(true)
|
||||||
const webfetcher = useWebFetcher()
|
const webfetcher = useWebFetcher()
|
||||||
const accountInfo = useAccount()
|
const accountInfo = useAccount()
|
||||||
|
const obsNotification = useOBSNotification()
|
||||||
|
|
||||||
const code = accountInfo.value.id ? accountInfo.value.biliAuthCode : window.$route.query.code?.toString()
|
const code = accountInfo.value.id ? accountInfo.value.biliAuthCode : window.$route.query.code?.toString()
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ const isGettingFivesingSongPlayUrl = ref(0)
|
|||||||
const uploadFiles = ref<UploadFileInfo[]>([])
|
const uploadFiles = ref<UploadFileInfo[]>([])
|
||||||
const uploadSongsFromFile = ref<SongsInfo[]>([])
|
const uploadSongsFromFile = ref<SongsInfo[]>([])
|
||||||
|
|
||||||
|
// 文件夹读取相关
|
||||||
|
const folderSongs = ref<SongsInfo[]>([])
|
||||||
|
const folderSongsOptions = ref<Option[]>([])
|
||||||
|
const selectedFolderSongs = ref<string[]>([])
|
||||||
|
const isScanningFolder = ref(false)
|
||||||
|
|
||||||
// 模态框加载状态
|
// 模态框加载状态
|
||||||
const isModalLoading = ref(false)
|
const isModalLoading = ref(false)
|
||||||
|
|
||||||
@@ -181,6 +187,265 @@ const uploadSongsOptions = computed(() => {
|
|||||||
|
|
||||||
const selecteduploadSongs = ref<string[]>([])
|
const selecteduploadSongs = ref<string[]>([])
|
||||||
|
|
||||||
|
// 支持的音频文件扩展名
|
||||||
|
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac', '.wma', '.ape']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择文件夹并扫描音频文件
|
||||||
|
*/
|
||||||
|
async function selectFolder() {
|
||||||
|
try {
|
||||||
|
// 检查浏览器是否支持 File System Access API
|
||||||
|
if (!('showDirectoryPicker' in window)) {
|
||||||
|
message.error('您的浏览器不支持文件夹选择功能,请使用最新版本的 Chrome、Edge 或其他现代浏览器')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isScanningFolder.value = true
|
||||||
|
folderSongs.value = []
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const directoryHandle = await window.showDirectoryPicker({
|
||||||
|
mode: 'read'
|
||||||
|
})
|
||||||
|
|
||||||
|
message.info('正在扫描文件夹...')
|
||||||
|
|
||||||
|
const audioFiles: { name: string, file: File, path: string }[] = []
|
||||||
|
|
||||||
|
// 递归扫描文件夹
|
||||||
|
await scanDirectory(directoryHandle, audioFiles, '')
|
||||||
|
|
||||||
|
if (audioFiles.length === 0) {
|
||||||
|
message.warning('未在文件夹中找到音频文件')
|
||||||
|
isScanningFolder.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message.info(`找到 ${audioFiles.length} 个音频文件,正在解析...`)
|
||||||
|
|
||||||
|
// 解析音频文件信息
|
||||||
|
for (const audioFile of audioFiles) {
|
||||||
|
const songInfo = parseAudioFileName(audioFile.name, audioFile.file, audioFile.path)
|
||||||
|
if (songInfo) {
|
||||||
|
folderSongs.value.push(songInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新选项
|
||||||
|
updateFolderSongsOptions()
|
||||||
|
|
||||||
|
message.success(`成功解析 ${folderSongs.value.length} 首歌曲`)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
message.info('已取消选择')
|
||||||
|
} else {
|
||||||
|
console.error(err)
|
||||||
|
message.error(`扫描文件夹失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isScanningFolder.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归扫描目录
|
||||||
|
*/
|
||||||
|
async function scanDirectory(
|
||||||
|
directoryHandle: any,
|
||||||
|
audioFiles: { name: string, file: File, path: string }[],
|
||||||
|
currentPath: string
|
||||||
|
) {
|
||||||
|
for await (const entry of directoryHandle.values()) {
|
||||||
|
const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
|
||||||
|
|
||||||
|
if (entry.kind === 'file') {
|
||||||
|
// 检查是否为音频文件
|
||||||
|
const ext = entry.name.substring(entry.name.lastIndexOf('.')).toLowerCase()
|
||||||
|
if (AUDIO_EXTENSIONS.includes(ext)) {
|
||||||
|
const file = await entry.getFile()
|
||||||
|
audioFiles.push({
|
||||||
|
name: entry.name,
|
||||||
|
file: file,
|
||||||
|
path: entryPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (entry.kind === 'directory') {
|
||||||
|
// 递归扫描子目录
|
||||||
|
await scanDirectory(entry, audioFiles, entryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析音频文件名,提取歌曲信息
|
||||||
|
* 支持的格式:
|
||||||
|
* - "歌名.mp3"
|
||||||
|
* - "歌手 - 歌名.mp3"
|
||||||
|
* - "歌名 - 歌手.mp3"
|
||||||
|
* - "歌手-歌名.mp3"
|
||||||
|
* - "[歌手] 歌名.mp3"
|
||||||
|
* - "歌手 《歌名》.mp3"
|
||||||
|
*/
|
||||||
|
function parseAudioFileName(fileName: string, file: File, filePath: string): SongsInfo | null {
|
||||||
|
// 移除文件扩展名
|
||||||
|
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'))
|
||||||
|
|
||||||
|
let name = ''
|
||||||
|
let author: string[] = []
|
||||||
|
|
||||||
|
// 尝试各种格式
|
||||||
|
|
||||||
|
// 格式: "歌手 - 歌名" 或 "歌名 - 歌手"
|
||||||
|
if (nameWithoutExt.includes(' - ')) {
|
||||||
|
const parts = nameWithoutExt.split(' - ').map(p => p.trim())
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// 假设第一部分是歌手,第二部分是歌名
|
||||||
|
author = parts[0].split(/[/、&]/).map(a => a.trim()).filter(a => a)
|
||||||
|
name = parts.slice(1).join(' - ')
|
||||||
|
|
||||||
|
// 如果第二部分更像歌手名(包含多个分隔符),交换
|
||||||
|
if (parts[1].match(/[/、&]/)) {
|
||||||
|
name = parts[0]
|
||||||
|
author = parts.slice(1).join(' - ').split(/[/、&]/).map(a => a.trim()).filter(a => a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 格式: "歌手-歌名" (无空格)
|
||||||
|
else if (nameWithoutExt.includes('-') && !nameWithoutExt.startsWith('-')) {
|
||||||
|
const parts = nameWithoutExt.split('-').map(p => p.trim())
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
author = parts[0].split(/[/、&]/).map(a => a.trim()).filter(a => a)
|
||||||
|
name = parts.slice(1).join('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 格式: "[歌手] 歌名" 或 "【歌手】歌名"
|
||||||
|
else if (nameWithoutExt.match(/^[[【](.+?)[\]】]\s*(.+)$/)) {
|
||||||
|
const match = nameWithoutExt.match(/^[[【](.+?)[\]】]\s*(.+)$/)
|
||||||
|
if (match) {
|
||||||
|
author = match[1].split(/[/、&]/).map(a => a.trim()).filter(a => a)
|
||||||
|
name = match[2].trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 格式: "歌手 《歌名》" 或 "歌手《歌名》"
|
||||||
|
else if (nameWithoutExt.match(/^(.+?)\s*[《<](.+?)[》>]$/)) {
|
||||||
|
const match = nameWithoutExt.match(/^(.+?)\s*[《<](.+?)[》>]$/)
|
||||||
|
if (match) {
|
||||||
|
author = match[1].split(/[/、&]/).map(a => a.trim()).filter(a => a)
|
||||||
|
name = match[2].trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 默认: 整个文件名作为歌名
|
||||||
|
else {
|
||||||
|
name = nameWithoutExt.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个本地URL(用于后续可能的播放预览)
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
key: '',
|
||||||
|
name,
|
||||||
|
author: author.length > 0 ? author : ['未知'],
|
||||||
|
url,
|
||||||
|
description: `从文件导入: ${filePath}`,
|
||||||
|
language: [],
|
||||||
|
tags: [],
|
||||||
|
from: SongFrom.Custom,
|
||||||
|
createTime: Date.now(),
|
||||||
|
updateTime: Date.now(),
|
||||||
|
// 存储原始文件信息,以便后续可能需要上传
|
||||||
|
// @ts-ignore
|
||||||
|
_originalFile: file,
|
||||||
|
// @ts-ignore
|
||||||
|
_filePath: filePath
|
||||||
|
} as SongsInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文件夹歌曲选项
|
||||||
|
*/
|
||||||
|
function updateFolderSongsOptions(newlyAddedSongs: SongsInfo[] = []) {
|
||||||
|
folderSongsOptions.value = folderSongs.value.map(s => ({
|
||||||
|
label: `${s.name} - ${s.author?.join('/') || '未知'}`,
|
||||||
|
value: s.name + '_' + (s as any)._filePath, // 使用组合键避免重名
|
||||||
|
disabled:
|
||||||
|
songs.value.findIndex(exist => exist.name === s.name) > -1
|
||||||
|
|| newlyAddedSongs.findIndex(add => add.name === s.name) > -1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加从文件夹选择的歌曲
|
||||||
|
*/
|
||||||
|
async function addFolderSongs() {
|
||||||
|
if (selectedFolderSongs.value.length === 0) {
|
||||||
|
message.error('请选择要添加的歌曲')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isModalLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const songsToAdd = folderSongs.value.filter(s =>
|
||||||
|
selectedFolderSongs.value.find(select => select === (s.name + '_' + (s as any)._filePath))
|
||||||
|
)
|
||||||
|
|
||||||
|
// 注意: 由于歌曲URL是本地Blob URL,需要根据实际需求处理
|
||||||
|
// 选项1: 直接使用本地URL(仅在当前会话有效)
|
||||||
|
// 选项2: 上传文件到服务器(需要实现文件上传接口)
|
||||||
|
// 这里使用选项1,但添加提示
|
||||||
|
|
||||||
|
const result = await addSongs(songsToAdd.map(s => ({
|
||||||
|
...s,
|
||||||
|
description: (s.description || '') + ' [注意: 链接为本地文件,刷新页面后可能失效]'
|
||||||
|
})), SongFrom.Custom)
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
message.success(`已添加 ${result.data.length} 首歌曲`)
|
||||||
|
songs.value.push(...result.data)
|
||||||
|
|
||||||
|
// 更新选项禁用状态
|
||||||
|
updateFolderSongsOptions(result.data)
|
||||||
|
} else {
|
||||||
|
message.error(`添加失败: ${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(`添加失败: ${err.message}`)
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
isModalLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量编辑文件夹歌曲信息
|
||||||
|
*/
|
||||||
|
function batchEditFolderSongs(field: 'author' | 'language' | 'tags', value: string[]) {
|
||||||
|
const selectedSongs = folderSongs.value.filter(s =>
|
||||||
|
selectedFolderSongs.value.find(select => select === (s.name + '_' + (s as any)._filePath))
|
||||||
|
)
|
||||||
|
|
||||||
|
selectedSongs.forEach(song => {
|
||||||
|
if (field === 'author') {
|
||||||
|
song.author = value
|
||||||
|
} else if (field === 'language') {
|
||||||
|
song.language = value
|
||||||
|
} else if (field === 'tags') {
|
||||||
|
song.tags = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新选项显示
|
||||||
|
updateFolderSongsOptions()
|
||||||
|
message.success(`已更新 ${selectedSongs.length} 首歌曲的${field === 'author' ? '作者' : field === 'language' ? '语言' : '标签'}信息`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加自定义歌曲
|
* 添加自定义歌曲
|
||||||
*/
|
*/
|
||||||
@@ -1351,7 +1616,114 @@ onMounted(async () => {
|
|||||||
name="directory"
|
name="directory"
|
||||||
tab="从文件夹读取"
|
tab="从文件夹读取"
|
||||||
>
|
>
|
||||||
开发中...
|
<NAlert
|
||||||
|
type="info"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
功能说明
|
||||||
|
</template>
|
||||||
|
<NSpace vertical>
|
||||||
|
<div>选择本地文件夹,自动扫描其中的音频文件(支持 MP3、WAV、OGG、FLAC、M4A 等格式)</div>
|
||||||
|
<div>支持的文件名格式:</div>
|
||||||
|
<ul style="margin: 8px 0; padding-left: 20px">
|
||||||
|
<li>歌名.mp3</li>
|
||||||
|
<li>歌手 - 歌名.mp3</li>
|
||||||
|
<li>歌手-歌名.mp3</li>
|
||||||
|
<li>[歌手] 歌名.mp3</li>
|
||||||
|
<li>歌手 《歌名》.mp3</li>
|
||||||
|
</ul>
|
||||||
|
<div style="color: #ff6b6b">
|
||||||
|
<strong>注意:</strong>导入的歌曲链接为本地文件地址,仅在当前浏览器会话有效。刷新页面后可能需要重新导入。
|
||||||
|
</div>
|
||||||
|
</NSpace>
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="isScanningFolder"
|
||||||
|
@click="selectFolder"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon :component="ArchiveOutline" />
|
||||||
|
</template>
|
||||||
|
选择文件夹
|
||||||
|
</NButton>
|
||||||
|
|
||||||
|
<template v-if="folderSongsOptions.length > 0">
|
||||||
|
<NDivider style="margin: 16px 0" />
|
||||||
|
|
||||||
|
<!-- 批量编辑工具 -->
|
||||||
|
<NCollapse>
|
||||||
|
<NCollapseItem
|
||||||
|
title="批量编辑工具"
|
||||||
|
name="batch-edit"
|
||||||
|
>
|
||||||
|
<NSpace
|
||||||
|
vertical
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<NAlert type="info">
|
||||||
|
选中歌曲后,可以批量设置作者、语言或标签信息
|
||||||
|
</NAlert>
|
||||||
|
<NSpace align="center">
|
||||||
|
<span>批量设置作者:</span>
|
||||||
|
<NSelect
|
||||||
|
style="width: 300px"
|
||||||
|
:options="authors"
|
||||||
|
filterable
|
||||||
|
multiple
|
||||||
|
tag
|
||||||
|
placeholder="选择或输入作者"
|
||||||
|
@update:value="(value) => batchEditFolderSongs('author', value)"
|
||||||
|
/>
|
||||||
|
</NSpace>
|
||||||
|
<NSpace align="center">
|
||||||
|
<span>批量设置语言:</span>
|
||||||
|
<NSelect
|
||||||
|
style="width: 300px"
|
||||||
|
:options="languageSelectOption"
|
||||||
|
filterable
|
||||||
|
multiple
|
||||||
|
tag
|
||||||
|
placeholder="选择或输入语言"
|
||||||
|
@update:value="(value) => batchEditFolderSongs('language', value)"
|
||||||
|
/>
|
||||||
|
</NSpace>
|
||||||
|
<NSpace align="center">
|
||||||
|
<span>批量设置标签:</span>
|
||||||
|
<NSelect
|
||||||
|
style="width: 300px"
|
||||||
|
:options="tags"
|
||||||
|
filterable
|
||||||
|
multiple
|
||||||
|
tag
|
||||||
|
placeholder="选择或输入标签"
|
||||||
|
@update:value="(value) => batchEditFolderSongs('tags', value)"
|
||||||
|
/>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
|
||||||
|
<NDivider style="margin: 16px 0" />
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
@click="addFolderSongs"
|
||||||
|
>
|
||||||
|
添加到歌单 | {{ selectedFolderSongs.length }} 首
|
||||||
|
</NButton>
|
||||||
|
|
||||||
|
<NDivider style="margin: 16px 0" />
|
||||||
|
|
||||||
|
<NTransfer
|
||||||
|
v-model:value="selectedFolderSongs"
|
||||||
|
style="height: 500px"
|
||||||
|
:options="folderSongsOptions"
|
||||||
|
source-filterable
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
</NTabs>
|
</NTabs>
|
||||||
</NSpin>
|
</NSpin>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import ClassicRequestOBS from './live-request/ClassicRequestOBS.vue'
|
import ClassicRequestOBS from './live-request/ClassicRequestOBS.vue'
|
||||||
import FreshRequestOBS from './live-request/FreshRequestOBS.vue'
|
import FreshRequestOBS from './live-request/FreshRequestOBS.vue'
|
||||||
|
import { useOBSNotification } from '@/store/useOBSNotification'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id?: number
|
id?: number
|
||||||
@@ -22,6 +23,12 @@ const styleType = computed(() => {
|
|||||||
const queryStyle = route.query.style
|
const queryStyle = route.query.style
|
||||||
return props.style || (typeof queryStyle === 'string' ? queryStyle : 'classic')
|
return props.style || (typeof queryStyle === 'string' ? queryStyle : 'classic')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const obsNotification = useOBSNotification()
|
||||||
|
onMounted(() => {
|
||||||
|
// 只接收 live-request 类型的通知
|
||||||
|
void obsNotification.init(['live-request'])
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { QueryGetAPI } from '@/api/query'
|
import { QueryGetAPI } from '@/api/query'
|
||||||
import { AVATAR_URL, MUSIC_REQUEST_API_URL } from '@/data/constants'
|
import { AVATAR_URL, MUSIC_REQUEST_API_URL } from '@/data/constants'
|
||||||
import { useWebRTC } from '@/store/useRTC'
|
import { useWebRTC } from '@/store/useRTC'
|
||||||
|
import { useOBSNotification } from '@/store/useOBSNotification'
|
||||||
|
|
||||||
interface WaitMusicInfo {
|
interface WaitMusicInfo {
|
||||||
from: DanmakuUserInfo
|
from: DanmakuUserInfo
|
||||||
@@ -64,7 +65,11 @@ async function update() {
|
|||||||
originSongs.value = r
|
originSongs.value = r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const obsNotification = useOBSNotification()
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 只接收 live-request 类型的通知
|
||||||
|
void obsNotification.init(['live-request'])
|
||||||
update()
|
update()
|
||||||
window.$mitt.on('onOBSComponentUpdate', () => {
|
window.$mitt.on('onOBSComponentUpdate', () => {
|
||||||
update()
|
update()
|
||||||
@@ -313,9 +318,6 @@ onUnmounted(() => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹幕点歌 */
|
|
||||||
.music-request-list-item[from='1'] {}
|
|
||||||
|
|
||||||
.music-request-list-item-name {
|
.music-request-list-item-name {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { QueryGetAPI } from '@/api/query'
|
import { QueryGetAPI } from '@/api/query'
|
||||||
import { QUEUE_API_URL } from '@/data/constants'
|
import { QUEUE_API_URL } from '@/data/constants'
|
||||||
import { useWebRTC } from '@/store/useRTC'
|
import { useWebRTC } from '@/store/useRTC'
|
||||||
|
import { useOBSNotification } from '@/store/useOBSNotification'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id?: number
|
id?: number
|
||||||
@@ -159,7 +160,10 @@ async function update() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const obsNotification = useOBSNotification()
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 只接收 queue 类型的通知
|
||||||
|
void obsNotification.init(['queue'])
|
||||||
update()
|
update()
|
||||||
window.$mitt.on('onOBSComponentUpdate', () => {
|
window.$mitt.on('onOBSComponentUpdate', () => {
|
||||||
update()
|
update()
|
||||||
|
|||||||
@@ -241,10 +241,10 @@ const hasOtherSingSong = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
{{
|
{{
|
||||||
hasOtherSingSong
|
hasOtherSingSong
|
||||||
? '还有其他正在演唱的歌曲'
|
? '还有其他正在进行的点播'
|
||||||
: song.status == SongRequestStatus.Waiting && song.id
|
: song.status == SongRequestStatus.Waiting && song.id
|
||||||
? '开始演唱'
|
? '开始处理'
|
||||||
: '停止演唱'
|
: '停止处理'
|
||||||
}}
|
}}
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
<NTooltip>
|
<NTooltip>
|
||||||
@@ -263,45 +263,56 @@ const hasOtherSingSong = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
完成
|
完成
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
<NPopconfirm
|
<NTooltip>
|
||||||
@positive-click="onUpdateStatus(SongRequestStatus.Cancel)"
|
|
||||||
>
|
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton
|
<NPopconfirm
|
||||||
circle
|
@positive-click="onUpdateStatus(SongRequestStatus.Cancel)"
|
||||||
type="error"
|
|
||||||
style="height: 30px; width: 30px"
|
|
||||||
:loading="isLoading"
|
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #trigger>
|
||||||
<NIcon :component="Dismiss16Filled" />
|
<NButton
|
||||||
|
circle
|
||||||
|
type="error"
|
||||||
|
style="height: 30px; width: 30px"
|
||||||
|
:loading="isLoading"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon :component="Dismiss16Filled" />
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
</template>
|
</template>
|
||||||
</NButton>
|
是否取消处理?
|
||||||
|
</NPopconfirm>
|
||||||
</template>
|
</template>
|
||||||
是否取消处理?
|
取消
|
||||||
</NPopconfirm>
|
</NTooltip>
|
||||||
<NPopconfirm
|
<NTooltip
|
||||||
v-if="
|
v-if="
|
||||||
song.from == SongRequestFrom.Danmaku
|
song.from == SongRequestFrom.Danmaku
|
||||||
&& song.user?.uid
|
&& song.user?.uid
|
||||||
&& song.status !== SongRequestStatus.Cancel
|
&& song.status !== SongRequestStatus.Cancel
|
||||||
"
|
"
|
||||||
@positive-click="onBlockUser"
|
|
||||||
>
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton
|
<NPopconfirm
|
||||||
circle
|
@positive-click="onBlockUser"
|
||||||
type="error"
|
|
||||||
style="height: 30px; width: 30px"
|
|
||||||
:loading="isLoading"
|
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #trigger>
|
||||||
<NIcon :component="PresenceBlocked16Regular" />
|
<NButton
|
||||||
|
circle
|
||||||
|
type="error"
|
||||||
|
style="height: 30px; width: 30px"
|
||||||
|
:loading="isLoading"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon :component="PresenceBlocked16Regular" />
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
</template>
|
</template>
|
||||||
</NButton>
|
是否拉黑此用户?
|
||||||
|
</NPopconfirm>
|
||||||
</template>
|
</template>
|
||||||
是否拉黑此用户?
|
拉黑用户
|
||||||
</NPopconfirm>
|
</NTooltip>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|||||||
@@ -292,6 +292,13 @@ async function updateSettings() {
|
|||||||
</NButton>
|
</NButton>
|
||||||
内的歌曲
|
内的歌曲
|
||||||
</NCheckbox>
|
</NCheckbox>
|
||||||
|
<NCheckbox
|
||||||
|
v-model:checked="accountInfo.settings.songRequest.allowReorderSong"
|
||||||
|
:disabled="!liveRequest.configCanEdit"
|
||||||
|
@update:checked="updateSettings"
|
||||||
|
>
|
||||||
|
允许重复点歌
|
||||||
|
</NCheckbox>
|
||||||
<NCheckbox
|
<NCheckbox
|
||||||
v-model:checked="accountInfo.settings.songRequest.allowFromWeb"
|
v-model:checked="accountInfo.settings.songRequest.allowFromWeb"
|
||||||
:disabled="!liveRequest.configCanEdit"
|
:disabled="!liveRequest.configCanEdit"
|
||||||
|
|||||||
135
本地提问保存功能说明.md
135
本地提问保存功能说明.md
@@ -1,135 +0,0 @@
|
|||||||
# 本地提问保存功能说明
|
|
||||||
|
|
||||||
## 功能概述
|
|
||||||
为未登录用户实现了本地提问历史保存功能,使用 VueUse 的 `useStorage` 将提问保存到浏览器的 IndexedDB 中。
|
|
||||||
|
|
||||||
## 实现的功能
|
|
||||||
|
|
||||||
### 1. 自动保存提问
|
|
||||||
- **触发时机**: 未登录用户成功发送提问后自动保存
|
|
||||||
- **保存内容**:
|
|
||||||
- 提问ID(本地生成)
|
|
||||||
- 目标用户ID和用户名
|
|
||||||
- 提问内容
|
|
||||||
- 话题标签
|
|
||||||
- 匿名昵称
|
|
||||||
- 匿名邮箱
|
|
||||||
- 是否包含图片
|
|
||||||
- 发送时间
|
|
||||||
|
|
||||||
### 2. 本地记录按钮
|
|
||||||
- **位置**: 在"我要提问"按钮旁边
|
|
||||||
- **显示条件**: 仅对未登录用户显示
|
|
||||||
- **功能**:
|
|
||||||
- 显示本地记录数量徽章
|
|
||||||
- 点击打开本地提问历史抽屉
|
|
||||||
|
|
||||||
### 3. 本地提问历史抽屉
|
|
||||||
- **布局**: 右侧抽屉,宽度 500px
|
|
||||||
- **功能**:
|
|
||||||
- 查看所有本地保存的提问
|
|
||||||
- 显示每条提问的详细信息
|
|
||||||
- 删除单条记录
|
|
||||||
- 清空所有记录
|
|
||||||
- 提示数据仅保存在浏览器中
|
|
||||||
|
|
||||||
### 4. 记录卡片显示
|
|
||||||
每条本地提问记录包含:
|
|
||||||
- 目标用户名称
|
|
||||||
- 话题标签(如果有)
|
|
||||||
- 提问内容
|
|
||||||
- 发送时间(格式化显示)
|
|
||||||
- 匿名昵称(如果有)
|
|
||||||
- 匿名邮箱(如果有)
|
|
||||||
- 图片标识(如果有)
|
|
||||||
- 删除按钮
|
|
||||||
|
|
||||||
## 技术实现
|
|
||||||
|
|
||||||
### 使用的技术栈
|
|
||||||
- **VueUse**: `useStorage` - 用于持久化存储
|
|
||||||
- **Naive UI**: 提供 UI 组件(Drawer、Badge、Card 等)
|
|
||||||
- **TypeScript**: 类型安全
|
|
||||||
- **Vue 3**: 组合式 API
|
|
||||||
|
|
||||||
### 数据结构
|
|
||||||
```typescript
|
|
||||||
interface LocalQuestion {
|
|
||||||
id: string // 本地生成的唯一ID
|
|
||||||
targetUserId: number // 目标用户ID
|
|
||||||
targetUserName: string // 目标用户名称
|
|
||||||
message: string // 提问内容
|
|
||||||
tag: string | null // 话题标签
|
|
||||||
anonymousName: string // 匿名昵称
|
|
||||||
anonymousEmail: string // 匿名邮箱
|
|
||||||
hasImage: boolean // 是否包含图片
|
|
||||||
sendAt: number // 发送时间戳
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 存储方式
|
|
||||||
```typescript
|
|
||||||
const localQuestions = useStorage<LocalQuestion[]>(
|
|
||||||
'vtsuru-local-questions', // 存储键名
|
|
||||||
[], // 默认值
|
|
||||||
undefined, // 使用默认存储(localStorage/sessionStorage)
|
|
||||||
{
|
|
||||||
serializer: {
|
|
||||||
read: (v: any) => v ? JSON.parse(v) : [],
|
|
||||||
write: (v: any) => JSON.stringify(v),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 新增的功能函数
|
|
||||||
|
|
||||||
### 1. deleteLocalQuestion
|
|
||||||
```typescript
|
|
||||||
function deleteLocalQuestion(id: string)
|
|
||||||
```
|
|
||||||
删除指定ID的本地提问记录
|
|
||||||
|
|
||||||
### 2. clearAllLocalQuestions
|
|
||||||
```typescript
|
|
||||||
function clearAllLocalQuestions()
|
|
||||||
```
|
|
||||||
清空所有本地提问记录
|
|
||||||
|
|
||||||
## UI 组件更新
|
|
||||||
|
|
||||||
### 新增导入
|
|
||||||
- `History24Regular` - 历史图标
|
|
||||||
- `NDrawer` - 抽屉组件
|
|
||||||
- `NDrawerContent` - 抽屉内容
|
|
||||||
- `NBadge` - 徽章组件
|
|
||||||
- `useStorage` from '@vueuse/core' - 存储hook
|
|
||||||
|
|
||||||
### 样式更新
|
|
||||||
- 提问按钮区域改为横向布局
|
|
||||||
- 新增本地历史按钮样式
|
|
||||||
- 新增本地提问卡片样式
|
|
||||||
- 优化过渡动画
|
|
||||||
|
|
||||||
## 用户体验优化
|
|
||||||
|
|
||||||
1. **自动保存**: 发送成功后自动保存,无需用户手动操作
|
|
||||||
2. **徽章提示**: 按钮上显示记录数量,直观了解保存的提问数
|
|
||||||
3. **详细信息**: 记录所有相关信息,方便用户回顾
|
|
||||||
4. **便捷管理**: 支持单条删除和批量清空
|
|
||||||
5. **数据提示**: 明确告知数据保存在本地浏览器中
|
|
||||||
6. **响应式设计**: 抽屉宽度适配不同屏幕
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **数据持久性**: 数据保存在浏览器本地存储,清除浏览器数据会丢失
|
|
||||||
2. **仅未登录用户**: 已登录用户可以在"我发送的"中查看完整历史
|
|
||||||
3. **图片信息**: 仅记录是否包含图片,不保存图片内容
|
|
||||||
4. **跨设备**: 记录仅在当前浏览器可见,不跨设备同步
|
|
||||||
|
|
||||||
## 使用场景
|
|
||||||
|
|
||||||
- 未登录用户想回顾自己发送的提问
|
|
||||||
- 确认提问是否成功发送
|
|
||||||
- 管理和清理本地提问记录
|
|
||||||
- 离线查看已发送的提问内容
|
|
||||||
208
测试指南-本地提问保存.md
208
测试指南-本地提问保存.md
@@ -1,208 +0,0 @@
|
|||||||
# 测试指南 - 本地提问保存功能
|
|
||||||
|
|
||||||
## 测试前准备
|
|
||||||
|
|
||||||
1. 确保已安装依赖:
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 启动开发服务器:
|
|
||||||
```bash
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试步骤
|
|
||||||
|
|
||||||
### 测试场景 1: 未登录用户发送提问并保存
|
|
||||||
|
|
||||||
1. **进入页面**
|
|
||||||
- 访问任意用户的提问箱页面
|
|
||||||
- 确保处于未登录状态(退出登录)
|
|
||||||
|
|
||||||
2. **查看按钮**
|
|
||||||
- 应该能看到两个按钮:
|
|
||||||
- "我要提问" (绿色主按钮)
|
|
||||||
- "本地记录" (蓝色按钮,带数字徽章)
|
|
||||||
- 初始状态徽章应该为 0 或不显示
|
|
||||||
|
|
||||||
3. **发送第一条提问**
|
|
||||||
- 点击"我要提问"按钮
|
|
||||||
- 填写提问内容(至少3个字)
|
|
||||||
- 可选:填写昵称和邮箱
|
|
||||||
- 可选:选择话题标签
|
|
||||||
- 可选:上传图片(如果主播允许)
|
|
||||||
- 完成人机验证
|
|
||||||
- 点击"发送"按钮
|
|
||||||
|
|
||||||
4. **验证自动保存**
|
|
||||||
- 发送成功后
|
|
||||||
- "本地记录"按钮的徽章数字应该增加到 1
|
|
||||||
- 提问表单应该自动收起
|
|
||||||
|
|
||||||
5. **查看本地记录**
|
|
||||||
- 点击"本地记录"按钮
|
|
||||||
- 应该打开右侧抽屉
|
|
||||||
- 能看到刚才发送的提问
|
|
||||||
- 显示内容包括:
|
|
||||||
- 目标用户名称
|
|
||||||
- 话题标签(如果有)
|
|
||||||
- 提问内容
|
|
||||||
- 发送时间
|
|
||||||
- 昵称(如果填写了)
|
|
||||||
- 邮箱(如果填写了)
|
|
||||||
- 图片标识(如果上传了)
|
|
||||||
|
|
||||||
### 测试场景 2: 发送多条提问
|
|
||||||
|
|
||||||
1. **重复发送**
|
|
||||||
- 继续发送 2-3 条提问
|
|
||||||
- 每次发送成功后,徽章数字应该增加
|
|
||||||
|
|
||||||
2. **查看记录列表**
|
|
||||||
- 打开本地记录抽屉
|
|
||||||
- 应该看到所有发送的提问
|
|
||||||
- 最新的提问在最上面
|
|
||||||
- 每条记录都显示完整信息
|
|
||||||
|
|
||||||
### 测试场景 3: 删除单条记录
|
|
||||||
|
|
||||||
1. **删除操作**
|
|
||||||
- 打开本地记录抽屉
|
|
||||||
- 找到想删除的记录
|
|
||||||
- 点击记录右上角的删除按钮(X图标)
|
|
||||||
- 应该显示"已删除"提示
|
|
||||||
|
|
||||||
2. **验证删除**
|
|
||||||
- 记录应该从列表中消失
|
|
||||||
- 徽章数字应该减少 1
|
|
||||||
- 其他记录不受影响
|
|
||||||
|
|
||||||
### 测试场景 4: 清空所有记录
|
|
||||||
|
|
||||||
1. **清空操作**
|
|
||||||
- 打开本地记录抽屉
|
|
||||||
- 点击右上角的"清空全部"按钮
|
|
||||||
- 应该显示"已清空所有本地提问记录"提示
|
|
||||||
|
|
||||||
2. **验证清空**
|
|
||||||
- 所有记录都应该消失
|
|
||||||
- 显示空状态提示:"还没有本地提问记录"
|
|
||||||
- 徽章数字应该消失或变为 0
|
|
||||||
|
|
||||||
### 测试场景 5: 已登录用户不显示本地记录
|
|
||||||
|
|
||||||
1. **登录账号**
|
|
||||||
- 使用任意账号登录
|
|
||||||
|
|
||||||
2. **验证显示**
|
|
||||||
- "本地记录"按钮应该不显示
|
|
||||||
- 只显示"我要提问"和"我发送的"按钮
|
|
||||||
- 已登录用户应该使用"我发送的"查看历史
|
|
||||||
|
|
||||||
### 测试场景 6: 数据持久性
|
|
||||||
|
|
||||||
1. **刷新页面**
|
|
||||||
- 发送几条提问后
|
|
||||||
- 刷新浏览器页面
|
|
||||||
- 本地记录应该仍然存在
|
|
||||||
|
|
||||||
2. **关闭重开**
|
|
||||||
- 关闭浏览器标签页
|
|
||||||
- 重新打开页面
|
|
||||||
- 本地记录应该仍然存在
|
|
||||||
|
|
||||||
3. **清除数据**
|
|
||||||
- 在浏览器开发者工具中
|
|
||||||
- 清除本地存储(localStorage)
|
|
||||||
- 本地记录应该被清空
|
|
||||||
|
|
||||||
### 测试场景 7: 不同用户的提问
|
|
||||||
|
|
||||||
1. **访问用户A的提问箱**
|
|
||||||
- 发送一条提问
|
|
||||||
- 记录应该保存目标用户名为"用户A"
|
|
||||||
|
|
||||||
2. **访问用户B的提问箱**
|
|
||||||
- 发送一条提问
|
|
||||||
- 记录应该保存目标用户名为"用户B"
|
|
||||||
|
|
||||||
3. **查看本地记录**
|
|
||||||
- 打开本地记录抽屉
|
|
||||||
- 应该看到两条记录
|
|
||||||
- 分别标注了不同的目标用户
|
|
||||||
|
|
||||||
## 浏览器兼容性测试
|
|
||||||
|
|
||||||
建议在以下浏览器中测试:
|
|
||||||
- ✅ Chrome / Edge (推荐)
|
|
||||||
- ✅ Firefox
|
|
||||||
- ✅ Safari
|
|
||||||
- ⚠️ IE (不支持)
|
|
||||||
|
|
||||||
## 开发者工具检查
|
|
||||||
|
|
||||||
### 查看存储数据
|
|
||||||
|
|
||||||
1. 打开浏览器开发者工具 (F12)
|
|
||||||
2. 切换到 Application/Storage 标签
|
|
||||||
3. 找到 Local Storage
|
|
||||||
4. 查找键名:`vtsuru-local-questions`
|
|
||||||
5. 数据应该是 JSON 格式的数组
|
|
||||||
|
|
||||||
示例数据:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "local-1234567890-abc123",
|
|
||||||
"targetUserId": 123,
|
|
||||||
"targetUserName": "测试用户",
|
|
||||||
"message": "这是一条测试提问",
|
|
||||||
"tag": "测试话题",
|
|
||||||
"anonymousName": "匿名用户",
|
|
||||||
"anonymousEmail": "test@example.com",
|
|
||||||
"hasImage": false,
|
|
||||||
"sendAt": 1234567890000
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 已知限制
|
|
||||||
|
|
||||||
1. **存储大小**: 浏览器 localStorage 有大小限制(通常 5-10MB)
|
|
||||||
2. **跨域隔离**: 不同域名下的数据互不可见
|
|
||||||
3. **无备份**: 清除浏览器数据会丢失所有记录
|
|
||||||
4. **图片不保存**: 只记录是否包含图片,不保存图片内容
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
### 问题:本地记录不显示
|
|
||||||
- 检查是否处于未登录状态
|
|
||||||
- 检查浏览器是否禁用了 localStorage
|
|
||||||
- 打开开发者工具查看是否有错误
|
|
||||||
|
|
||||||
### 问题:发送成功但没有保存
|
|
||||||
- 检查开发者工具 Console 是否有错误
|
|
||||||
- 确认 userInfo 对象有正确的 id 和 name
|
|
||||||
- 检查 isUserLoggedIn 计算属性是否正确
|
|
||||||
|
|
||||||
### 问题:刷新后记录消失
|
|
||||||
- 检查浏览器隐私模式设置
|
|
||||||
- 确认没有自动清除 Cookie 和存储的设置
|
|
||||||
- 检查浏览器扩展是否影响存储
|
|
||||||
|
|
||||||
## 性能考虑
|
|
||||||
|
|
||||||
- ✅ 使用 VueUse 的 `useStorage` 自动处理序列化
|
|
||||||
- ✅ 数据保存是同步的,不影响发送速度
|
|
||||||
- ✅ 列表渲染使用虚拟滚动(如果记录很多)
|
|
||||||
- ⚠️ 建议不要保存超过 1000 条记录
|
|
||||||
|
|
||||||
## 反馈与改进
|
|
||||||
|
|
||||||
如果在测试过程中发现问题,请记录:
|
|
||||||
1. 浏览器类型和版本
|
|
||||||
2. 操作步骤
|
|
||||||
3. 预期结果
|
|
||||||
4. 实际结果
|
|
||||||
5. 控制台错误信息(如果有)
|
|
||||||
Reference in New Issue
Block a user