diff --git a/bun.lockb b/bun.lockb index af3dc6d..c245fb7 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a4bfbc5..5ad9497 100644 --- a/package.json +++ b/package.json @@ -11,29 +11,29 @@ "knip": "knip" }, "dependencies": { - "@hyperdx/browser": "^0.21.2", + "@hyperdx/browser": "^0.22.0", "@hyperdx/cli": "^0.1.0", - "@microsoft/signalr": "^9.0.6", - "@microsoft/signalr-protocol-msgpack": "^9.0.6", + "@microsoft/signalr": "^10.0.0", + "@microsoft/signalr-protocol-msgpack": "^10.0.0", "@mixer/postmessage-rpc": "^1.1.4", "@oneidentity/zstd-js": "^1.0.3", - "@tauri-apps/api": "^2.8.0", - "@tauri-apps/plugin-autostart": "^2.5.0", - "@tauri-apps/plugin-http": "^2.5.2", - "@tauri-apps/plugin-log": "^2.7.0", - "@tauri-apps/plugin-notification": "^2.3.1", - "@tauri-apps/plugin-opener": "^2.5.0", - "@tauri-apps/plugin-os": "^2.3.1", - "@tauri-apps/plugin-process": "^2.3.0", - "@tauri-apps/plugin-store": "^2.4.0", + "@tauri-apps/api": "^2.9.0", + "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-http": "^2.5.4", + "@tauri-apps/plugin-log": "^2.7.1", + "@tauri-apps/plugin-notification": "^2.3.3", + "@tauri-apps/plugin-opener": "^2.5.2", + "@tauri-apps/plugin-os": "^2.3.2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-store": "^2.4.1", "@tauri-apps/plugin-updater": "^2.9.0", "@types/crypto-js": "^4.2.2", - "@types/md5": "^2.3.5", + "@types/md5": "^2.3.6", "@vicons/fluent": "^0.13.0", "@vitejs/plugin-vue": "^6.0.1", - "@vueuse/core": "^13.9.0", - "@vueuse/integrations": "^13.9.0", - "@vueuse/router": "^13.9.0", + "@vueuse/core": "^14.0.0", + "@vueuse/integrations": "^14.0.0", + "@vueuse/router": "^14.0.0", "@wangeditor/editor": "^5.1.23", "@wangeditor/editor-for-vue": "^5.1.12", "bilibili-live-danmaku": "^0.7.14", @@ -41,7 +41,7 @@ "date-fns": "^4.1.0", "easy-speech": "^2.4.0", "echarts": "^6.0.0", - "fast-xml-parser": "^5.2.5", + "fast-xml-parser": "^5.3.2", "file-saver": "^2.0.5", "grapheme-splitter": "^1.0.4", "html2canvas": "^1.4.1", @@ -52,24 +52,25 @@ "lodash-es": "^4.17.21", "md5": "^2.3.0", "mitt": "^3.0.1", - "monaco-editor": "^0.53.0", - "naive-ui": "2.42.0", + "monaco-editor": "^0.54.0", + "naive-ui": "2.43.2", "nanoid": "^5.1.6", + "obs-websocket-js": "^5.0.7", "peerjs": "^1.5.5", - "pinia": "^3.0.3", + "pinia": "^3.0.4", "qrcode.vue": "^3.6.0", "unplugin-auto-import": "^20.2.0", - "unplugin-vue-components": "^29.1.0", + "unplugin-vue-components": "^30.0.0", "unplugin-vue-markdown": "^29.2.0", "uuid": "^13.0.0", - "vite": "npm:rolldown-vite@latest", + "vite": "npm:rolldown-vite@7.2.5", "vite-plugin-monaco-editor-nls": "^3.0.1", "vite-svg-loader": "^5.1.0", - "vue": "3.5.22", - "vue-echarts": "^8.0.0", + "vue": "3.5.24", + "vue-echarts": "^8.0.1", "vue-img-cutter": "^3.0.7", "vue-request": "^2.0.4", - "vue-router": "^4.5.1", + "vue-router": "^4.6.3", "vue-toastification": "^1.7.14", "vue-turnstile": "^1.0.11", "vue3-aplayer": "^1.7.3", @@ -79,22 +80,22 @@ "xlsx": "^0.18.5" }, "devDependencies": { - "@antfu/eslint-config": "^5.4.1", - "@types/bun": "^1.2.23", + "@antfu/eslint-config": "^6.2.0", + "@types/bun": "^1.3.2", "@types/file-saver": "^2.0.7", "@types/jszip": "^3.4.1", "@types/uuid": "^11.0.0", "@vicons/ionicons5": "^0.13.0", "@vitejs/plugin-vue-jsx": "^5.1.1", - "@vue-vine/eslint-config": "^1.1.9", - "eslint": "^9.36.0", - "eslint-plugin-oxlint": "^1.19.0", - "oxlint": "^1.19.0", - "rollup-plugin-visualizer": "^6.0.4", + "@vue-vine/eslint-config": "^1.1.11", + "eslint": "^9.39.1", + "eslint-plugin-oxlint": "^1.28.0", + "oxlint": "^1.28.0", + "rollup-plugin-visualizer": "^6.0.5", "stylus": "^0.64.0", - "typescript": "^5.9.2", + "typescript": "^5.9.3", "vite-plugin-cdn-import": "^1.0.1", "vscode-loc": "git+https://github.com/microsoft/vscode-loc.git", - "vue-vine": "^1.7.6" + "vue-vine": "^1.7.23" } } diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 9ff7970..d753e08 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -208,6 +208,7 @@ declare global { const lastDayOfYear: typeof import('date-fns')['lastDayOfYear'] const lightFormat: typeof import('date-fns')['lightFormat'] const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable'] + const manualResetRef: typeof import('@vueuse/core')['manualResetRef'] const mapActions: typeof import('pinia')['mapActions'] const mapGetters: typeof import('pinia')['mapGetters'] const mapState: typeof import('pinia')['mapState'] @@ -282,6 +283,7 @@ declare global { const refAutoReset: typeof import('@vueuse/core')['refAutoReset'] const refDebounced: typeof import('@vueuse/core')['refDebounced'] const refDefault: typeof import('@vueuse/core')['refDefault'] + const refManualReset: typeof import('@vueuse/core')['refManualReset'] const refThrottled: typeof import('@vueuse/core')['refThrottled'] const refWithControl: typeof import('@vueuse/core')['refWithControl'] const resolveComponent: typeof import('vue')['resolveComponent'] diff --git a/src/client/ClientFetcher.vue b/src/client/ClientFetcher.vue index 71f5a6f..1be6df7 100644 --- a/src/client/ClientFetcher.vue +++ b/src/client/ClientFetcher.vue @@ -71,6 +71,7 @@ import { NRadioGroup, NSpin, NStatistic, + NSwitch, NTabPane, NTabs, NTag, @@ -85,7 +86,7 @@ import { useAccount } from '@/api/account' import { useWebFetcher } from '@/store/useWebFetcher' import { getLoginInfoAsync, getLoginUrlDataAsync } from './data/biliLogin' import { currentStatistic, getHistoricalStatistics, streamingInfo } from './data/info' -import { callStartDanmakuClient } from './data/initialize' +import { callStartDanmakuClient, resetDanmakuClientInitState } from './data/initialize' import { COOKIE_CLOUD_KEY, useBiliCookie } from './store/useBiliCookie' import { useSettings } from './store/useSettings' import { useTauriStore } from './store/useTauriStore' @@ -607,6 +608,30 @@ async function logout() { message.info('已退出登录') } +// 处理 EventFetcher 开关切换 +async function handleToggleEventFetcher(enabled: boolean) { + await settings.save() + + if (enabled) { + // 启用 EventFetcher + message.info('正在启动 EventFetcher...') + const result = await callStartDanmakuClient() + if (result.success) { + message.success('EventFetcher 已启动') + } else { + message.error(`EventFetcher 启动失败: ${result.message}`) + } + } else { + // 禁用 EventFetcher + if (webfetcher.state !== 'disconnected') { + webfetcher.Stop() + message.info('EventFetcher 已停止') + } + // 重置弹幕客户端初始化状态,确保重新启用时能正确连接 + resetDanmakuClientInitState() + } +} + // --- Watchers --- watch(() => webfetcher.state, (newState) => { if (newState === 'connected') { @@ -727,33 +752,101 @@ onUnmounted(() => { embedded style="width: 100%; max-width: 800px;" > - - - - 开放平台 - - + +
+ - -
diff --git a/src/client/api/live-manage.ts b/src/client/api/live-manage.ts new file mode 100644 index 0000000..a94d55a --- /dev/null +++ b/src/client/api/live-manage.ts @@ -0,0 +1,582 @@ +import { QueryBiliAPI } from '../data/utils' +import { useBiliCookie } from '../store/useBiliCookie' +import CryptoJS from 'crypto-js' +import { fetch as tauriFetch } from '@tauri-apps/plugin-http' + +/** + * 直播姬版本信息 + */ +export interface LiveVersionInfo { + curr_version: string // 直播姬最新版本号 + build: number // 直播姬构建号 + instruction: string // 更新说明(简要) + file_size: string // 文件大小(字节) + file_md5: string // 安装包文件MD5 + content: string // HTML格式的更新内容 + download_url: string // 安装包下载链接 + hdiffpatch_switch: number // 增量更新开关 +} + +/** + * MD5 哈希实现 - 使用 crypto-js + */ +function md5(str: string): string { + return CryptoJS.MD5(str).toString() +} + +/** + * APP签名函数 - 用于B站API签名 + * @param params 已包含appkey的参数字典 + * @param appsec app secret密钥 + */ +function appSign(params: Record, appsec: string): Record { + // 按 key 排序参数 + const sortedKeys = Object.keys(params).sort() + const sortedParams: Record = {} + sortedKeys.forEach(key => { + sortedParams[key] = params[key] + }) + + // 序列化参数为 key=value&key=value 格式 + const queryString = sortedKeys.map(key => `${key}=${params[key]}`).join('&') + + // 计算 MD5 签名 + const signString = queryString + appsec + const sign = md5(signString) + + console.log('签名字符串:', signString) + console.log('签名结果:', sign) + + // 添加签名 + sortedParams.sign = sign + + return sortedParams +} + +/** + * 获取当前时间戳 + */ +async function getTimestamp(): Promise { + try { + const resp = await QueryBiliAPI( + 'https://api.bilibili.com/x/report/click/now', + 'GET', + '', + false, + ) + const json = await resp.json() + if (json.code === 0 && json.data?.now) { + return json.data.now + } + } + catch (err) { + console.error('获取服务器时间戳失败,使用本地时间:', err) + } + return Math.floor(Date.now() / 1000) +} + +/** + * 获取直播姬版本号 + */ +export async function getLiveVersion(): Promise { + try { + console.log('正在获取直播姬版本号') + const appkey = 'aae92bc66f3edfab' + const appsec = 'af125a0d5279fd576c1b4418a3e8276d' + + const ts = await getTimestamp() + + // 准备参数并签名 + const params = appSign({ + appkey: appkey, + system_version: 2, + ts, + }, appsec) + + const query = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + query.append(key, String(value)) + }) + + const resp = await QueryBiliAPI( + `https://api.live.bilibili.com/xlive/app-blink/v1/liveVersionInfo/getHomePageLiveVersion?${query.toString()}`, + 'GET', + '', + false, + ) + const json = await resp.json() + + if (json.code === 0 && json.data) { + console.log('获取直播姬版本成功:', json.data.curr_version, 'build:', json.data.build) + return json.data + } + + return null + } + catch (err) { + console.error('获取直播姬版本失败:', err) + return null + } +} + +/** + * 直播间管理API + */ + +/** + * 开始直播 + * @param roomId 直播间ID + * @param areaV2 直播分区ID(子分区ID) + * @param platform 直播平台 pc | pc_link | android_link + * @param version 直播姬版本号(可选) + * @param build 直播姬构建号(可选) + */ +export interface StartLiveParams { + roomId: number + areaV2: number + platform?: 'pc' | 'pc_link' | 'android_link' + version?: string + build?: number +} + +export interface StartLiveResponse { + code: number + msg: string + message: string + data?: { + change: number + status: string + room_type: number + rtmp: { + addr: string // RTMP推流地址 + code: string // RTMP推流参数(密钥) + new_link: string + provider: string + } + protocols: Array<{ + protocol: string + addr: string + code: string + new_link: string + provider: string + }> + try_time: string + live_key: string + sub_session_key: string + notice: any + qr?: string // 人脸认证二维码 + need_face_auth: boolean + service_source: string + rtmp_backup: any + up_stream_extra: { + isp: string + } + } +} + +// 开播错误码 +export enum StartLiveErrorCode { + SUCCESS = 0, + NEED_FACE_AUTH = 60024, // 需要人脸认证 +} + +/** + * 开始直播 + */ +export async function startLive(params: StartLiveParams): Promise { + const biliCookieStore = useBiliCookie() + const cookie = await biliCookieStore.getBiliCookie() + + console.log('正在开始直播: ', params) + + if (!cookie) { + throw new Error('未登录或Cookie无效') + } + + // 从cookie中提取bili_jct作为csrf + const csrfMatch = cookie.match(/bili_jct=([^;]+)/) + const csrf = csrfMatch ? csrfMatch[1] : '' + + if (!csrf) { + throw new Error('无法获取CSRF令牌') + } + + // 准备参数 + const appkey = 'aae92bc66f3edfab' + const appsec = 'af125a0d5279fd576c1b4418a3e8276d' + + // 获取时间戳 + const ts = await getTimestamp() + + const requestParams: Record = { + access_key: '', // 留空 + appkey: appkey, + platform: params.platform || 'pc_link', + room_id: params.roomId, + area_v2: params.areaV2, + build: params.build?.toString() || '9343', + backup_stream: 0, + csrf: csrf, + csrf_token: csrf, + ts: ts.toString(), + } + + // 对参数按字典序排序并签名 + const signedParams = appSign(requestParams, appsec) + + console.log('已对参数进行签名') + console.log('开播请求参数:', signedParams) + + // 将参数作为URL查询字符串,而不是POST body + const query = new URLSearchParams() + Object.entries(signedParams).forEach(([key, value]) => { + query.append(key, String(value)) + }) + + const resp = await QueryBiliAPI( + `https://api.live.bilibili.com/room/v1/Room/startLive?${query.toString()}`, + 'POST', + cookie, + true, + ) + + const json = await resp.json() + console.log('开播响应:', json) + return json as StartLiveResponse +} + +/** + * 关闭直播 + */ +export interface StopLiveParams { + roomId: number + platform?: 'pc' | 'pc_link' | 'android_link' +} + +export interface StopLiveResponse { + code: number + msg: string + message: string + data?: { + change: number + status: string + } +} + +/** + * 关闭直播 + */ +export async function stopLive(params: StopLiveParams): Promise { + const biliCookieStore = useBiliCookie() + const cookie = await biliCookieStore.getBiliCookie() + + if (!cookie) { + throw new Error('未登录或Cookie无效') + } + + const csrfMatch = cookie.match(/bili_jct=([^;]+)/) + const csrf = csrfMatch ? csrfMatch[1] : '' + + if (!csrf) { + throw new Error('无法获取CSRF令牌') + } + + const formData = new URLSearchParams() + formData.append('platform', params.platform || 'pc_link') + formData.append('room_id', params.roomId.toString()) + formData.append('csrf', csrf) + + const resp = await QueryBiliAPI( + 'https://api.live.bilibili.com/room/v1/Room/stopLive', + 'POST', + cookie, + true, + formData, + ) + + const json = await resp.json() + return json as StopLiveResponse +} + +/** + * 更新直播间信息 + */ +export interface UpdateRoomParams { + roomId: number + title?: string + areaId?: number + addTag?: string + delTag?: string +} + +export interface UpdateRoomResponse { + code: number + msg: string + message: string + data?: { + sub_session_key: string + audit_info: { + audit_title_reason: string + audit_title_status: number + audit_title?: string + update_title: string + } + } +} + +/** + * 更新直播间信息 + */ +export async function updateRoom(params: UpdateRoomParams): Promise { + const biliCookieStore = useBiliCookie() + const cookie = await biliCookieStore.getBiliCookie() + + if (!cookie) { + throw new Error('未登录或Cookie无效') + } + + const csrfMatch = cookie.match(/bili_jct=([^;]+)/) + const csrf = csrfMatch ? csrfMatch[1] : '' + + if (!csrf) { + throw new Error('无法获取CSRF令牌') + } + + const formData = new URLSearchParams() + formData.append('room_id', params.roomId.toString()) + formData.append('csrf', csrf) + formData.append('csrf_token', csrf) + + if (params.title !== undefined) { + formData.append('title', params.title) + } + if (params.areaId !== undefined) { + formData.append('area_id', params.areaId.toString()) + } + if (params.addTag !== undefined) { + formData.append('add_tag', params.addTag) + } + if (params.delTag !== undefined) { + formData.append('del_tag', params.delTag) + } + + const resp = await QueryBiliAPI( + 'https://api.live.bilibili.com/room/v1/Room/update', + 'POST', + cookie, + true, + formData, + ) + + const json = await resp.json() + return json as UpdateRoomResponse +} + +/** + * 获取直播分区列表 + */ +export interface LiveArea { + id: number + name: string + parent_id: number + parent_name: string +} + +export async function getLiveAreas(): Promise { + const resp = await QueryBiliAPI('https://api.live.bilibili.com/room/v1/Area/getList', 'GET', '', false) + const json = await resp.json() + + if (json.code === 0 && json.data) { + const areas: LiveArea[] = [] + for (const parent of json.data) { + for (const child of parent.list) { + areas.push({ + id: child.id, + name: child.name, + parent_id: parent.id, + parent_name: parent.name, + }) + } + } + return areas + } + + throw new Error('获取直播分区失败') +} + +export interface UpdateRoomNewsParams { + roomId: number + content: string +} + +export interface UpdateRoomNewsResponse { + code: number + message: string + data: any + ttl?: number +} + +export async function updateRoomNews(params: UpdateRoomNewsParams): Promise { + const biliCookieStore = useBiliCookie() + const cookie = await biliCookieStore.getBiliCookie() + + if (!cookie) { + throw new Error('未登录或Cookie无效') + } + + const csrfMatch = cookie.match(/bili_jct=([^;]+)/) + const csrf = csrfMatch ? csrfMatch[1] : '' + + const uidMatch = cookie.match(/DedeUserID=([^;]+)/) + const uid = uidMatch ? uidMatch[1] : '' + + if (!csrf || !uid) { + throw new Error('无法获取CSRF令牌或用户ID') + } + + const formData = new URLSearchParams() + formData.append('room_id', params.roomId.toString()) + formData.append('uid', uid) + formData.append('content', params.content ?? '') + formData.append('csrf', csrf) + formData.append('csrf_token', csrf) + + const resp = await QueryBiliAPI( + 'https://api.live.bilibili.com/xlive/app-blink/v1/index/updateRoomNews', + 'POST', + cookie, + true, + formData, + ) + + const json = await resp.json() + return json as UpdateRoomNewsResponse +} + +export interface UploadCoverResult { + location: string + etag?: string + image_url?: string +} + +export interface UploadCoverResponse { + code: number + message: string + data?: UploadCoverResult +} + +export async function uploadCover(file: File): Promise { + const biliCookieStore = useBiliCookie() + const cookie = await biliCookieStore.getBiliCookie() + + if (!cookie) { + throw new Error('未登录或Cookie无效') + } + + const csrfMatch = cookie.match(/bili_jct=([^;]+)/) + const csrf = csrfMatch ? csrfMatch[1] : '' + + if (!csrf) { + throw new Error('无法获取CSRF令牌') + } + + const apiUrl = 'https://api.bilibili.com/x/upload/web/image' + const boundary = '----WebKitFormBoundary' + Math.random().toString(16).slice(2) + + const encoder = new TextEncoder() + + const parts: string[] = [] + parts.push( + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="bucket"\r\n\r\n' + + 'live\r\n', + ) + parts.push( + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="dir"\r\n\r\n' + + 'new_room_cover\r\n', + ) + parts.push( + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="csrf"\r\n\r\n' + + `${csrf}\r\n`, + ) + parts.push( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${file.name || 'blob'}"\r\n` + + `Content-Type: ${file.type || 'image/jpeg'}\r\n\r\n`, + ) + + const headBytes = encoder.encode(parts.join('')) + const fileBytes = new Uint8Array(await file.arrayBuffer()) + const tailBytes = encoder.encode(`\r\n--${boundary}--\r\n`) + + const body = new Uint8Array(headBytes.length + fileBytes.length + tailBytes.length) + body.set(headBytes, 0) + body.set(fileBytes, headBytes.length) + body.set(tailBytes, headBytes.length + fileBytes.length) + + const headers: Record = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', + 'Origin': 'https://www.bilibili.com', + 'Referer': 'https://live.bilibili.com/', + 'Cookie': cookie, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + } + + const resp = await tauriFetch(apiUrl, { + method: 'POST', + headers, + body, + }) + + if (!resp.ok) { + throw new Error(`上传封面失败: HTTP ${resp.status} ${resp.statusText}`) + } + + const json = await resp.json() + return json as UploadCoverResponse +} + +export interface UpdateCoverResponse { + code: number + message: string + data?: any +} + +export async function updateCover(coverUrl: string): Promise { + const biliCookieStore = useBiliCookie() + const cookie = await biliCookieStore.getBiliCookie() + + if (!cookie) { + throw new Error('未登录或Cookie无效') + } + + const csrfMatch = cookie.match(/bili_jct=([^;]+)/) + const csrf = csrfMatch ? csrfMatch[1] : '' + + if (!csrf) { + throw new Error('无法获取CSRF令牌') + } + + const formData = new URLSearchParams() + formData.append('platform', 'web') + formData.append('mobi_app', 'web') + formData.append('build', '1') + formData.append('cover', coverUrl) + formData.append('coverVertical', '') + formData.append('liveDirectionType', '1') + formData.append('csrf', csrf) + formData.append('csrf_token', csrf) + + const resp = await QueryBiliAPI( + 'https://api.live.bilibili.com/xlive/app-blink/v1/preLive/UpdatePreLiveInfo', + 'POST', + cookie, + true, + formData, + ) + + const json = await resp.json() + return json as UpdateCoverResponse +} diff --git a/src/client/data/initialize.ts b/src/client/data/initialize.ts index c054e24..c894d75 100644 --- a/src/client/data/initialize.ts +++ b/src/client/data/initialize.ts @@ -14,7 +14,7 @@ import { import { openUrl } from '@tauri-apps/plugin-opener' import { relaunch } from '@tauri-apps/plugin-process' import { check } from '@tauri-apps/plugin-updater' -import { h, ref } from 'vue' +import { h, ref, watch } from 'vue' import { isLoggedIn, useAccount } from '@/api/account' import { CN_HOST, isDev } from '@/data/constants' import { useWebFetcher } from '@/store/useWebFetcher' @@ -23,8 +23,9 @@ import { useBiliCookie } from '../store/useBiliCookie' import { useBiliFunction } from '../store/useBiliFunction' import { useDanmakuWindow } from '../store/useDanmakuWindow' import { useSettings } from '../store/useSettings' -import { initInfo } from './info' +import { initInfo, roomInfo } from './info' import { getBuvid, getRoomKey } from './utils' +import { useTauriStore } from '../store/useTauriStore' const accountInfo = useAccount() @@ -35,6 +36,14 @@ let heartbeatTimer: number | null = null let updateCheckTimer: number | null = null let updateNotificationRef: any = null +// interface RtmpRelayState { +// roomId: number +// targetRtmpUrl: string +// } + +// const RTMP_RELAY_STATE_KEY = 'webfetcher.rtmpRelay' +// let hasTriedAutoResumeRtmp = false + async function sendHeartbeat() { try { await invoke('heartbeat', undefined, { @@ -45,6 +54,49 @@ async function sendHeartbeat() { } } +// async function tryAutoResumeRtmpRelay() { +// if (hasTriedAutoResumeRtmp) return +// +// const store = useTauriStore() +// const saved = await store.get(RTMP_RELAY_STATE_KEY) +// if (!saved || !saved.roomId || !saved.targetRtmpUrl) { +// hasTriedAutoResumeRtmp = true +// return +// } +// +// const room = roomInfo.value +// if (!room || room.live_status !== 1) { +// return +// } +// +// if (room.room_id !== saved.roomId) { +// hasTriedAutoResumeRtmp = true +// return +// } +// +// try { +// // 如果已经在进行 RTMP 转发,则不再重复启动 +// try { +// const status = await invoke<{ is_relaying: boolean }>('get_rtmp_relay_status') +// if (status?.is_relaying) { +// info('[RTMP] 已在转发中,跳过自动恢复') +// hasTriedAutoResumeRtmp = true +// return +// } +// } +// catch (error) { +// warn(`[RTMP] 获取 RTMP 转发状态失败: ${error}`) +// } +// +// await invoke('start_rtmp_relay', { targetUrl: saved.targetRtmpUrl }) +// info('[RTMP] 检测到正在开播,已自动恢复 RTMP 转发') +// } catch (error) { +// warn(`[RTMP] 自动恢复 RTMP 转发失败: ${error}`) +// } finally { +// hasTriedAutoResumeRtmp = true +// } +// } + export function startHeartbeat() { // 立即发送一次,确保后端在加载后快速收到心跳 void sendHeartbeat() @@ -379,6 +431,12 @@ export async function initAll(isOnBoot: boolean) { startUpdateCheck() } + // void tryAutoResumeRtmpRelay() + + // watch(roomInfo, () => { + // void tryAutoResumeRtmpRelay() + // }) + clientInited.value = true clientInitStage.value = '启动完成' } @@ -400,6 +458,13 @@ export async function checkUpdate() { export const isInitedDanmakuClient = ref(false) export const isInitingDanmakuClient = ref(false) + +// 重置弹幕客户端初始化状态 +export function resetDanmakuClientInitState() { + isInitedDanmakuClient.value = false + isInitingDanmakuClient.value = false + info('弹幕客户端初始化状态已重置') +} export async function initDanmakuClient() { const biliCookie = useBiliCookie() const settings = useSettings() @@ -407,6 +472,13 @@ export async function initDanmakuClient() { info('弹幕客户端已初始化, 跳过初始化') return { success: true, message: '' } } + + // 检查是否启用 EventFetcher + if (!settings.settings.enableEventFetcher) { + info('EventFetcher 功能已禁用, 跳过弹幕客户端初始化') + return { success: true, message: 'EventFetcher 已禁用' } + } + isInitingDanmakuClient.value = true console.log(settings.settings) let result = { success: false, message: '' } diff --git a/src/client/data/utils.ts b/src/client/data/utils.ts index 636d91b..5d63280 100644 --- a/src/client/data/utils.ts +++ b/src/client/data/utils.ts @@ -5,7 +5,7 @@ import { OPEN_LIVE_API_URL } from '@/data/constants' import { useBiliCookie } from '../store/useBiliCookie' import { useBiliFunction } from '../store/useBiliFunction' -export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true) { +export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true, body?: string | URLSearchParams) { const u = new URL(url) console.log(`调用bilibili api: ${url}`) const userAgents = [ @@ -17,13 +17,21 @@ export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: ] const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)] + const headers: Record = { + 'User-Agent': randomUserAgent, + 'Origin': 'https://www.bilibili.com', + 'Referer': 'https://live.bilibili.com/', + 'Cookie': useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : '', + } + + if (body) { + headers['Content-Type'] = 'application/x-www-form-urlencoded' + } + return fetch(url, { method, - headers: { - 'User-Agent': randomUserAgent, - 'Origin': 'https://www.bilibili.com', - 'Cookie': useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : '', - }, + headers, + body: body instanceof URLSearchParams ? body.toString() : body, }) } diff --git a/src/client/store/useOBSStore.ts b/src/client/store/useOBSStore.ts new file mode 100644 index 0000000..050742b --- /dev/null +++ b/src/client/store/useOBSStore.ts @@ -0,0 +1,551 @@ +import { acceptHMRUpdate, defineStore } from 'pinia' +import { ref } from 'vue' +import OBSWebSocket from 'obs-websocket-js' +import { useTauriStore } from './useTauriStore' + +// OBS配置接口 +export interface ObsConfigState { + address: string + password?: string +} + +// 场景配置接口 +export interface ObsSceneConfig { + startScene?: string // 开播场景 + stopScene?: string // 下播场景 + waitingScene?: string // 等待场景 + autoSwitchEnabled: boolean // 是否启用自动切换 + autoToggleStream: boolean // 是否在开播下播后自动切换OBS推流状态 +} + +// OBS统计信息接口 +export interface ObsStats { + cpuUsage: number | null + memoryUsage: number | null + fps: number | null + averageRenderTimeMs: number | null + renderSkippedFrames: number | null + renderTotalFrames: number | null + outputSkippedFrames: number | null + outputTotalFrames: number | null + bitrateKbps: number | null +} + +export const useOBSStore = defineStore('obs', () => { + // 基础配置 + const OBS_CONFIG_KEY = 'webfetcher.obsConfig' + const OBS_SCENE_CONFIG_KEY = 'webfetcher.obsSceneConfig' + const tauriStore = useTauriStore() + + // 连接状态 + const obsAddress = ref('ws://127.0.0.1:4455') + const obsPassword = ref('') + const obsConnected = ref(false) + const obsConnecting = ref(false) + const obsError = ref('') + const obsAutoReconnect = ref(false) + + // 推流状态 + const obsStreamActive = ref(false) + const obsStreamReconnecting = ref(false) + const isTogglingObsStream = ref(false) + + // 统计信息 + const obsStats = ref({ + cpuUsage: null, + memoryUsage: null, + fps: null, + averageRenderTimeMs: null, + renderSkippedFrames: null, + renderTotalFrames: null, + outputSkippedFrames: null, + outputTotalFrames: null, + bitrateKbps: null, + }) + + // 场景控制 + const obsScenes = ref([]) + const currentObsScene = ref('') + const isSwitchingScene = ref(false) + const obsSceneError = ref('') + const obsSceneConfig = ref({ + autoSwitchEnabled: false, + autoToggleStream: true // 默认开启 + }) + + // OBS实例和定时器 + let obs: OBSWebSocket | null = null + let obsStatsTimer: number | null = null + let obsReconnectTimer: number | null = null + let lastObsBytes = 0 + let lastObsBytesTimestamp = 0 + + // 初始化OBS实例 + function ensureObsInstance() { + if (!obs) { + obs = new OBSWebSocket() + obs.on('ConnectionClosed', () => { + obsConnected.value = false + obsStreamActive.value = false + stopObsStatsLoop() + }) + } + } + + // 更新OBS统计信息 + async function updateObsStats() { + if (!obs || !obsConnected.value) return + + try { + const stats: any = await obs.call('GetStats') + obsStats.value.cpuUsage = typeof stats.cpuUsage === 'number' ? stats.cpuUsage : null + obsStats.value.memoryUsage = typeof stats.memoryUsage === 'number' ? stats.memoryUsage : null + obsStats.value.fps = typeof stats.activeFps === 'number' ? stats.activeFps : null + obsStats.value.averageRenderTimeMs = typeof stats.averageFrameRenderTime === 'number' ? stats.averageFrameRenderTime : null + obsStats.value.renderSkippedFrames = typeof stats.renderSkippedFrames === 'number' ? stats.renderSkippedFrames : null + obsStats.value.renderTotalFrames = typeof stats.renderTotalFrames === 'number' ? stats.renderTotalFrames : null + obsStats.value.outputSkippedFrames = typeof stats.outputSkippedFrames === 'number' ? stats.outputSkippedFrames : null + obsStats.value.outputTotalFrames = typeof stats.outputTotalFrames === 'number' ? stats.outputTotalFrames : null + + const streamStatus: any = await obs.call('GetStreamStatus') + obsStreamActive.value = !!streamStatus.outputActive + obsStreamReconnecting.value = !!streamStatus.outputReconnecting + + // 计算码率 + const now = Date.now() + const bytes = typeof streamStatus.outputBytes === 'number' ? streamStatus.outputBytes : 0 + if (lastObsBytesTimestamp && now > lastObsBytesTimestamp && bytes >= lastObsBytes) { + const deltaBytes = bytes - lastObsBytes + const deltaSeconds = (now - lastObsBytesTimestamp) / 1000 + if (deltaSeconds > 0) { + const kbps = (deltaBytes * 8) / 1000 / deltaSeconds + obsStats.value.bitrateKbps = Number.isFinite(kbps) ? kbps : null + } + } + lastObsBytes = bytes + lastObsBytesTimestamp = now + + // 获取当前场景 + await updateCurrentScene() + } + catch (err) { + console.error('获取 OBS 统计失败:', err) + } + } + + // 启动统计循环 + function startObsStatsLoop() { + if (obsStatsTimer !== null) return + obsStatsTimer = window.setInterval(() => { + void updateObsStats() + }, 1000) + } + + // 停止统计循环 + function stopObsStatsLoop() { + if (obsStatsTimer !== null) { + clearInterval(obsStatsTimer) + obsStatsTimer = null + } + } + + // 启动自动重连循环 + function startObsAutoReconnectLoop() { + if (obsReconnectTimer !== null) return + + // 如果当前条件满足,立即尝试连接一次 + if (obsAutoReconnect.value && + obsAddress.value && + obsPassword.value && + !obsConnected.value && + !obsConnecting.value) { + void handleObsConnect() + } + + // 启动定时重连循环 + obsReconnectTimer = window.setInterval(() => { + if (!obsAutoReconnect.value) return + if (!obsAddress.value) return + if (obsConnected.value || obsConnecting.value) return + // 确保地址和密码都已设置才尝试连接 + if (!obsPassword.value) return + void handleObsConnect() + }, 10000) + } + + // 停止自动重连循环 + function stopObsAutoReconnectLoop() { + if (obsReconnectTimer !== null) { + clearInterval(obsReconnectTimer) + obsReconnectTimer = null + } + } + + // 连接OBS + async function handleObsConnect() { + console.log('handleObsConnect called') + console.log('obsConnected:', obsConnected.value, 'obsConnecting:', obsConnecting.value) + + if (obsConnected.value || obsConnecting.value) { + console.log('Early return: already connected or connecting') + return + } + + console.log('Starting OBS connection process...') + obsError.value = '' + obsConnecting.value = true + + try { + ensureObsInstance() + if (!obs) { + throw new Error('OBS 实例未初始化') + } + + const address = obsAddress.value || 'ws://127.0.0.1:4455' + const password = obsPassword.value || undefined + + await obs.connect(address, password, { + rpcVersion: 1, + }) + + obsConnected.value = true + obsConnecting.value = false + obsAutoReconnect.value = true + startObsAutoReconnectLoop() + + // 保存配置 + try { + await tauriStore.set(OBS_CONFIG_KEY, { + address, + password: obsPassword.value || undefined, + } as ObsConfigState) + } + catch (err) { + console.error('保存 OBS 配置失败:', err) + } + + startObsStatsLoop() + void updateObsStats() + + // 连接成功后获取场景列表 + void fetchObsScenes() + } + catch (err: any) { + console.error('连接 OBS 失败:', err) + obsError.value = err?.message || String(err) + obsConnected.value = false + obsConnecting.value = false + } + } + + // 断开OBS连接 + async function handleObsDisconnect() { + obsError.value = '' + obsAutoReconnect.value = false + stopObsStatsLoop() + stopObsAutoReconnectLoop() + + try { + if (obs) { + await obs.disconnect() + } + } + catch (err) { + console.error('断开 OBS 失败:', err) + } + finally { + obsConnected.value = false + obsStreamActive.value = false + } + } + + // 切换推流状态 + async function handleObsToggleStream() { + if (!obs || !obsConnected.value) { + window.$message.error('请先连接 OBS') + return + } + + try { + isTogglingObsStream.value = true + const result: any = await obs.call('ToggleStream') + if (typeof result?.outputActive === 'boolean') { + obsStreamActive.value = result.outputActive + } + window.$message.success(obsStreamActive.value ? '已开始 OBS 推流' : '已停止 OBS 推流') + void updateObsStats() + } + catch (err: any) { + console.error('切换 OBS 推流状态失败:', err) + window.$message.error(`切换 OBS 推流状态失败: ${err?.message || err}`) + } + finally { + isTogglingObsStream.value = false + } + } + + // 开始推流 + async function startObsStream() { + if (!obs || !obsConnected.value) { + console.warn('OBS 未连接,无法开始推流') + return false + } + + if (obsStreamActive.value) { + console.log('OBS 已在推流中') + return true + } + + try { + isTogglingObsStream.value = true + await obs.call('StartStream') + obsStreamActive.value = true + window.$message.success('已开始 OBS 推流') + void updateObsStats() + return true + } + catch (err: any) { + console.error('开始 OBS 推流失败:', err) + window.$message.error(`开始 OBS 推流失败: ${err?.message || err}`) + return false + } + finally { + isTogglingObsStream.value = false + } + } + + // 停止推流 + async function stopObsStream() { + if (!obs || !obsConnected.value) { + console.warn('OBS 未连接,无法停止推流') + return false + } + + if (!obsStreamActive.value) { + console.log('OBS 未在推流中') + return true + } + + try { + isTogglingObsStream.value = true + await obs.call('StopStream') + obsStreamActive.value = false + window.$message.success('已停止 OBS 推流') + void updateObsStats() + return true + } + catch (err: any) { + console.error('停止 OBS 推流失败:', err) + window.$message.error(`停止 OBS 推流失败: ${err?.message || err}`) + return false + } + finally { + isTogglingObsStream.value = false + } + } + + // 同步推流码到 OBS + async function syncStreamKeyToObs(server: string, key: string) { + if (!obs || !obsConnected.value) { + window.$message.error('请先连接 OBS') + return false + } + + try { + // 获取当前的流设置 + const streamSettings: any = await obs.call('GetStreamServiceSettings') + + // 更新服务器和推流码 + await obs.call('SetStreamServiceSettings', { + streamServiceType: streamSettings.streamServiceType || 'rtmp_custom', + streamServiceSettings: { + ...streamSettings.streamServiceSettings, + server: server, + key: key + } + }) + + window.$message.success('推流码已同步到 OBS') + return true + } + catch (err: any) { + console.error('同步推流码到 OBS 失败:', err) + window.$message.error(`同步推流码失败: ${err?.message || err}`) + return false + } + } + + // 获取OBS场景列表 + async function fetchObsScenes() { + if (!obs || !obsConnected.value) return + + try { + const sceneList: any = await obs.call('GetSceneList') + obsScenes.value = sceneList.scenes.map((scene: any) => scene.sceneName as string) + console.log('获取到OBS场景列表:', obsScenes.value) + } + catch (err: any) { + console.error('获取OBS场景列表失败:', err) + obsSceneError.value = err?.message || '获取场景列表失败' + } + } + + // 更新当前场景 + async function updateCurrentScene() { + if (!obs || !obsConnected.value) return + + try { + const currentScene: any = await obs.call('GetCurrentProgramScene') + currentObsScene.value = currentScene.currentProgramSceneName || '' + } + catch (err: any) { + console.error('获取当前场景失败:', err) + } + } + + // 切换到指定场景 + async function switchToScene(sceneName: string): Promise { + if (!obs || !obsConnected.value) { + window.$message.error('OBS未连接') + return false + } + + if (!sceneName || !obsScenes.value.includes(sceneName)) { + window.$message.error('无效的场景名称') + return false + } + + // 防止重复切换到相同场景 + if (currentObsScene.value === sceneName) { + console.log(`已在场景: ${sceneName},无需切换`) + return true + } + + try { + isSwitchingScene.value = true + obsSceneError.value = '' + + await obs.call('SetCurrentProgramScene', { + sceneName: sceneName + }) + + currentObsScene.value = sceneName + console.log(`已切换到场景: ${sceneName}`) + window.$message.success(`已切换到场景: ${sceneName}`) + return true + } + catch (err: any) { + console.error('切换场景失败:', err) + obsSceneError.value = err?.message || '切换场景失败' + window.$message.error(`切换场景失败: ${err?.message || err}`) + return false + } + finally { + isSwitchingScene.value = false + } + } + + // 保存场景配置 + async function saveSceneConfig() { + try { + await tauriStore.set(OBS_SCENE_CONFIG_KEY, obsSceneConfig.value) + console.log('场景配置已保存') + } + catch (err) { + console.error('保存场景配置失败:', err) + } + } + + // 加载场景配置 + async function loadSceneConfig() { + try { + const saved = await tauriStore.get(OBS_SCENE_CONFIG_KEY) + if (saved) { + obsSceneConfig.value = saved + console.log('已加载场景配置:', saved) + } + } + catch (err) { + console.error('加载场景配置失败:', err) + } + } + + // 加载OBS配置 + async function loadObsConfig() { + try { + const saved = await tauriStore.get(OBS_CONFIG_KEY) + if (saved?.address) { + obsAddress.value = saved.address + // 只有在设置了地址和密码时才启用自动重连 + if (saved?.password !== undefined) { + obsPassword.value = saved.password || '' + obsAutoReconnect.value = true + } + } + } + catch (err) { + console.error('加载OBS配置失败:', err) + } + } + + // 初始化 + async function init() { + await loadObsConfig() + await loadSceneConfig() + + // 只有在设置了地址和密码后才启动自动重连 + if (obsAutoReconnect.value && obsAddress.value) { + startObsAutoReconnectLoop() + } + } + + // 清理资源 + function cleanup() { + stopObsAutoReconnectLoop() + stopObsStatsLoop() + if (obs) { + void obs.disconnect().catch(() => {}) + obs = null + } + } + + return { + // 状态 + obsAddress, + obsPassword, + obsConnected, + obsConnecting, + obsError, + obsAutoReconnect, + obsStreamActive, + obsStreamReconnecting, + isTogglingObsStream, + obsStats, + obsScenes, + currentObsScene, + isSwitchingScene, + obsSceneError, + obsSceneConfig, + + // 方法 + handleObsConnect, + handleObsDisconnect, + handleObsToggleStream, + startObsStream, + stopObsStream, + syncStreamKeyToObs, + fetchObsScenes, + updateCurrentScene, + switchToScene, + saveSceneConfig, + loadSceneConfig, + loadObsConfig, + init, + cleanup, + } +}) + +// 热模块替换支持 +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useOBSStore, import.meta.hot)) +} diff --git a/src/client/store/useSettings.ts b/src/client/store/useSettings.ts index 2d8fd40..70629a5 100644 --- a/src/client/store/useSettings.ts +++ b/src/client/store/useSettings.ts @@ -19,6 +19,9 @@ export interface VTsuruClientSettings { danmakuInterval: number pmInterval: number + // EventFetcher 功能开关 + enableEventFetcher: boolean + dev_disableDanmakuClient: boolean } @@ -40,6 +43,9 @@ export const useSettings = defineStore('settings', () => { danmakuInterval: 2000, pmInterval: 2000, + // 默认启用 EventFetcher + enableEventFetcher: true, + dev_disableDanmakuClient: false, } const settings = ref(Object.assign({}, defaultSettings)) @@ -51,6 +57,8 @@ export const useSettings = defineStore('settings', () => { // 初始化消息队列间隔设置 settings.value.danmakuInterval ??= defaultSettings.danmakuInterval settings.value.pmInterval ??= defaultSettings.pmInterval + // 初始化 EventFetcher 开关 + settings.value.enableEventFetcher ??= defaultSettings.enableEventFetcher } async function save() { await store.set(settings.value) diff --git a/src/components.d.ts b/src/components.d.ts index aa98134..95ca6f9 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -1,8 +1,12 @@ /* eslint-disable */ // @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 -// biome-ignore lint: disable +import { GlobalComponents } from 'vue' + export {} /* prettier-ignore */ @@ -19,11 +23,35 @@ declare module 'vue' { LabelItem: typeof import('./components/LabelItem.vue')['default'] 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'] + NCascader: typeof import('naive-ui')['NCascader'] + NDivider: typeof import('naive-ui')['NDivider'] + NEllipsis: typeof import('naive-ui')['NEllipsis'] + NEmpty: typeof import('naive-ui')['NEmpty'] NFlex: typeof import('naive-ui')['NFlex'] NFormItemGi: typeof import('naive-ui')['NFormItemGi'] + NGi: typeof import('naive-ui')['NGi'] + NGrid: typeof import('naive-ui')['NGrid'] NGridItem: typeof import('naive-ui')['NGridItem'] + NIcon: typeof import('naive-ui')['NIcon'] + NImage: typeof import('naive-ui')['NImage'] + NInput: typeof import('naive-ui')['NInput'] + NInputGroup: typeof import('naive-ui')['NInputGroup'] + NModal: typeof import('naive-ui')['NModal'] + NPopconfirm: typeof import('naive-ui')['NPopconfirm'] NScrollbar: typeof import('naive-ui')['NScrollbar'] + NSelect: typeof import('naive-ui')['NSelect'] + NSpace: typeof import('naive-ui')['NSpace'] + NStatistic: typeof import('naive-ui')['NStatistic'] + NSwitch: typeof import('naive-ui')['NSwitch'] NTag: typeof import('naive-ui')['NTag'] + NText: typeof import('naive-ui')['NText'] + NTime: typeof import('naive-ui')['NTime'] + NTooltip: typeof import('naive-ui')['NTooltip'] + NUpload: typeof import('naive-ui')['NUpload'] PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default'] PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default'] PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default'] @@ -46,3 +74,67 @@ declare module 'vue' { VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default'] } } + +// For TSX support +declare global { + const AddressDisplay: typeof import('./components/manage/AddressDisplay.vue')['default'] + const BiliUserSelector: typeof import('./components/common/BiliUserSelector.vue')['default'] + const DanmakuContainer: typeof import('./components/DanmakuContainer.vue')['default'] + const DanmakuItem: typeof import('./components/DanmakuItem.vue')['default'] + const DynamicForm: typeof import('./components/DynamicForm.vue')['default'] + const EventFetcherAlert: typeof import('./components/EventFetcherAlert.vue')['default'] + const EventFetcherStatusCard: typeof import('./components/EventFetcherStatusCard.vue')['default'] + const FeedbackItem: typeof import('./components/FeedbackItem.vue')['default'] + const LabelItem: typeof import('./components/LabelItem.vue')['default'] + const LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default'] + const MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default'] + const NAlert: typeof import('naive-ui')['NAlert'] + const NAvatar: typeof import('naive-ui')['NAvatar'] + const NButton: typeof import('naive-ui')['NButton'] + const NCard: typeof import('naive-ui')['NCard'] + const NCascader: typeof import('naive-ui')['NCascader'] + const NDivider: typeof import('naive-ui')['NDivider'] + const NEllipsis: typeof import('naive-ui')['NEllipsis'] + const NEmpty: typeof import('naive-ui')['NEmpty'] + const NFlex: typeof import('naive-ui')['NFlex'] + const NFormItemGi: typeof import('naive-ui')['NFormItemGi'] + const NGi: typeof import('naive-ui')['NGi'] + const NGrid: typeof import('naive-ui')['NGrid'] + const NGridItem: typeof import('naive-ui')['NGridItem'] + const NIcon: typeof import('naive-ui')['NIcon'] + const NImage: typeof import('naive-ui')['NImage'] + const NInput: typeof import('naive-ui')['NInput'] + const NInputGroup: typeof import('naive-ui')['NInputGroup'] + const NModal: typeof import('naive-ui')['NModal'] + const NPopconfirm: typeof import('naive-ui')['NPopconfirm'] + const NScrollbar: typeof import('naive-ui')['NScrollbar'] + const NSelect: typeof import('naive-ui')['NSelect'] + const NSpace: typeof import('naive-ui')['NSpace'] + const NStatistic: typeof import('naive-ui')['NStatistic'] + const NSwitch: typeof import('naive-ui')['NSwitch'] + const NTag: typeof import('naive-ui')['NTag'] + const NText: typeof import('naive-ui')['NText'] + const NTime: typeof import('naive-ui')['NTime'] + const NTooltip: typeof import('naive-ui')['NTooltip'] + const NUpload: typeof import('naive-ui')['NUpload'] + const PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default'] + const PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default'] + const PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default'] + const QuestionItem: typeof import('./components/QuestionItem.vue')['default'] + const QuestionItems: typeof import('./components/QuestionItems.vue')['default'] + const RegisterAndLogin: typeof import('./components/RegisterAndLogin.vue')['default'] + const RouterLink: typeof import('vue-router')['RouterLink'] + const RouterView: typeof import('vue-router')['RouterView'] + const SaveCompoent: typeof import('./components/SaveCompoent.vue')['default'] + const ScheduleList: typeof import('./components/ScheduleList.vue')['default'] + const SimpleVideoCard: typeof import('./components/SimpleVideoCard.vue')['default'] + const SimpleVirtualList: typeof import('./components/SimpleVirtualList.vue')['default'] + const SongList: typeof import('./components/SongList.vue')['default'] + const SongPlayer: typeof import('./components/SongPlayer.vue')['default'] + const TempComponent: typeof import('./components/TempComponent.vue')['default'] + const TurnstileVerify: typeof import('./components/TurnstileVerify.vue')['default'] + const UpdateNoteContainer: typeof import('./components/UpdateNoteContainer.vue')['default'] + const UserBasicInfoCard: typeof import('./components/UserBasicInfoCard.vue')['default'] + const VEditor: typeof import('./components/VEditor.vue')['default'] + const VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default'] +} \ No newline at end of file diff --git a/src/data/UpdateNote.ts b/src/data/UpdateNote.ts index d9afec5..22e82f7 100644 --- a/src/data/UpdateNote.ts +++ b/src/data/UpdateNote.ts @@ -3,6 +3,29 @@ import { NButton, NImage } from 'naive-ui' import UpdateNoteContainer from '@/components/UpdateNoteContainer.vue' export const updateNotes: updateNoteType[] = [ + { + ver: 9, + date: '2025.11.17', + items: [ + { + type: 'new', + title: 'VTsuru Client 新增直播管理功能', + content: [ + [ + () => h(NButton, { + text: true, + tag: 'a', + href: 'https://www.wolai.com/carN6qvUm3FErze9Xo53ii', + target: '_blank', + type: 'info', + }, () => 'VTsuru Client '), + ' 新增直播管理功能, 允许直接开播下播并使用OBS推流, 不再依赖直播姬\r\n', + () => h(NImage, { src: 'https://files.vtsuru.suki.club/updatelog/QQ20251117-182002.png', width: 300 }), + ], + ], + }, + ], + }, { ver: 8, date: '2025.10.16', diff --git a/src/data/obsConstants.ts b/src/data/obsConstants.ts index 8650a1b..4648c3d 100644 --- a/src/data/obsConstants.ts +++ b/src/data/obsConstants.ts @@ -1,4 +1,4 @@ -import type { Component, DefineComponent } from 'vue' +import { defineAsyncComponent, type Component, DefineComponent } from 'vue' /** * OBS 组件定义接口 diff --git a/src/router/client.ts b/src/router/client.ts index 02de324..3997d5f 100644 --- a/src/router/client.ts +++ b/src/router/client.ts @@ -59,6 +59,15 @@ export default { forceReload: true, }, }, + { + path: 'live-manage', + name: 'client-live-manage', + component: async () => import('@/client/ClientLiveManage.vue'), + meta: { + title: '直播管理', + forceReload: true, + }, + }, { path: 'danmaku-window', name: 'client-danmaku-window-redirect', diff --git a/src/views/ManageLayout.vue b/src/views/ManageLayout.vue index 97d1e38..df72a08 100644 --- a/src/views/ManageLayout.vue +++ b/src/views/ManageLayout.vue @@ -655,6 +655,10 @@ onMounted(() => { canResendEmail.value = true } } + + if (selectedAPIKey.value != 'main') { + message.warning('你当前使用的是备用API节点, 可能会速度比较慢') + } }) onUnmounted(() => {