diff --git a/bun.lockb b/bun.lockb index d968990..f5f509f 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.html b/index.html index e9d98f3..fa148af 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,7 @@ + diff --git a/package.json b/package.json index 9f91083..987b18b 100644 --- a/package.json +++ b/package.json @@ -11,21 +11,22 @@ "dependencies": { "@microsoft/signalr": "^8.0.7", "@microsoft/signalr-protocol-msgpack": "^8.0.7", - "@types/node": "^22.8.2", - "@typescript-eslint/eslint-plugin": "^8.12.1", + "@types/node": "^22.9.0", + "@typescript-eslint/eslint-plugin": "^8.13.0", "@vicons/fluent": "^0.12.0", + "@vitejs/plugin-basic-ssl": "^1.1.0", "@vitejs/plugin-vue": "^5.1.4", "@vue/cli": "^5.0.8", - "@vueuse/core": "^11.1.0", - "@vueuse/router": "^11.1.0", + "@vueuse/core": "^11.2.0", + "@vueuse/router": "^11.2.0", "@wangeditor/editor": "^5.1.23", "@wangeditor/editor-for-vue": "^5.1.12", "date-fns": "^4.1.0", "easy-speech": "^2.4.0", "echarts": "^5.5.1", - "eslint": "^9.13.0", + "eslint": "^9.14.0", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-oxlint": "^0.10.1", + "eslint-plugin-oxlint": "^0.11.0", "eslint-plugin-prettier": "^5.2.1", "fast-xml-parser": "^4.5.0", "file-saver": "^2.0.5", @@ -34,9 +35,10 @@ "linqts": "^2.0.0", "mitt": "^3.0.1", "music-metadata-browser": "^2.5.11", - "pinia": "^2.2.4", + "peerjs": "^1.5.4", + "pinia": "^2.2.6", "prettier": "^3.3.3", - "qrcode.vue": "^3.5.1", + "qrcode.vue": "^3.6.0", "queue-typescript": "^1.0.1", "unplugin-vue-markdown": "^0.26.2", "uuid": "^11.0.2", @@ -50,16 +52,16 @@ "vue3-aplayer": "^1.7.3", "vue3-marquee": "^4.2.2", "vueuc": "^0.4.64", - "worker-timers": "^8.0.10", + "worker-timers": "^8.0.11", "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3.1.0", - "@types/bun": "^1.1.12", + "@types/bun": "^1.1.13", "@types/eslint": "^9.6.1", "@types/obs-studio": "^2.17.2", "@types/uuid": "^10.0.0", - "@typescript-eslint/parser": "^8.12.1", + "@typescript-eslint/parser": "^8.13.0", "@vicons/ionicons5": "^0.12.0", "@vitejs/plugin-vue-jsx": "^4.0.1", "@vue/eslint-config-typescript": "^14.1.3", diff --git a/plugins/vite-plugin-caddy.ts b/plugins/vite-plugin-caddy.ts new file mode 100644 index 0000000..69df042 --- /dev/null +++ b/plugins/vite-plugin-caddy.ts @@ -0,0 +1,49 @@ +// src/index.ts +import chalk from 'chalk' +import { spawn } from 'child_process' + +// src/utils.ts +import { execSync } from 'child_process' +function validateCaddyIsInstalled() { + let caddyInstalled = false + try { + execSync('caddy version') + caddyInstalled = true + } catch { + caddyInstalled = false + console.error('caddy cli is not installed') + } + return caddyInstalled +} + +// src/index.ts +function viteCaddyTlsPlugin(url?:string) { + return { + name: 'vite:caddy-tls', + async configResolved({ command }) { + if (command !== 'serve') return + console.log('starting caddy plugin...') + validateCaddyIsInstalled() + const handle = spawn( + `caddy reverse-proxy ${url ? `--from ${url}` : ''} --to http://localhost:5173`, + { + shell: true + } + ) + handle.stdout.on('data', (data) => { + console.log(`stdout: ${data}`) + }) + handle.stderr.on('data', () => {}) + //const servers = parseNamesFromCaddyFile(`${cwd}/Caddyfile`); + console.log() + console.log( + chalk.green('\u{1F512} Caddy is running to proxy your traffic on https') + ) + console.log() + console.log(`\u{1F517} Access your local server `) + console.log(chalk.blue(`\u{1F30D} https://${url ?? 'localhost'}`)) + console.log() + } + } +} +export { viteCaddyTlsPlugin as default } diff --git a/src/api/account.ts b/src/api/account.ts index 64fe061..f544c07 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -9,7 +9,6 @@ import { useRoute } from 'vue-router' export const ACCOUNT = ref({} as AccountInfo) export const isLoadingAccount = ref(true) -const route = useRoute() const { message } = createDiscreteApi(['message']) const cookie = useLocalStorage('JWT_Token', '') @@ -47,6 +46,7 @@ export async function GetSelfAccount() { } export function UpdateAccountLoop() { setInterval(() => { + const route = useRoute() if (ACCOUNT.value && route?.name != 'question-display') { // 防止在问题详情页刷新 GetSelfAccount() @@ -63,47 +63,68 @@ function refreshCookie() { }) } export async function SaveAccountSettings() { - return await QueryPostAPI(ACCOUNT_API_URL + 'update-setting', ACCOUNT.value?.settings) + return await QueryPostAPI( + ACCOUNT_API_URL + 'update-setting', + ACCOUNT.value?.settings + ) } export async function SaveEnableFunctions(functions: FunctionTypes[]) { - return await QueryPostAPI(ACCOUNT_API_URL + 'update-enable-functions', functions) + return await QueryPostAPI( + ACCOUNT_API_URL + 'update-enable-functions', + functions + ) } export async function SaveSetting( - name: 'Queue' | 'Point' | 'Index' | 'General' | 'QuestionDisplay' | 'SongRequest' | 'QuestionBox' | 'SendEmail', - setting: unknown, + name: + | 'Queue' + | 'Point' + | 'Index' + | 'General' + | 'QuestionDisplay' + | 'SongRequest' + | 'QuestionBox' + | 'SendEmail', + setting: unknown ) { const result = await QueryPostAPIWithParams( ACCOUNT_API_URL + 'update-single-setting', { - name, + name }, - setting, + setting ) return result.message } export async function UpdateFunctionEnable(func: FunctionTypes) { if (ACCOUNT.value) { - const oldValue = JSON.parse(JSON.stringify(ACCOUNT.value.settings.enableFunctions)) + const oldValue = JSON.parse( + JSON.stringify(ACCOUNT.value.settings.enableFunctions) + ) if (ACCOUNT.value?.settings.enableFunctions.includes(func)) { - ACCOUNT.value.settings.enableFunctions = ACCOUNT.value.settings.enableFunctions.filter((f) => f != func) + ACCOUNT.value.settings.enableFunctions = + ACCOUNT.value.settings.enableFunctions.filter((f) => f != func) } else { ACCOUNT.value.settings.enableFunctions.push(func) } await SaveEnableFunctions(ACCOUNT.value?.settings.enableFunctions) .then((data) => { if (data.code == 200) { - message.success(`已${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}`) + message.success( + `已${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}` + ) } else { if (ACCOUNT.value) { ACCOUNT.value.settings.enableFunctions = oldValue } message.error( - `${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}失败: ${data.message}`, + `${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}失败: ${data.message}` ) } }) .catch((err) => { - message.error(`${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}失败: ${err}`) + message.error( + `${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}失败: ${err}` + ) }) } } @@ -111,74 +132,85 @@ export function useAccount() { return ACCOUNT } -export async function Register(name: string, email: string, password: string, token: string): Promise> { +export async function Register( + name: string, + email: string, + password: string, + token: string +): Promise> { return QueryPostAPI(`${ACCOUNT_API_URL}register`, { name, email, password, - token, + token }) } -export async function Login(nameOrEmail: string, password: string): Promise> { +export async function Login( + nameOrEmail: string, + password: string +): Promise> { return QueryPostAPI(`${ACCOUNT_API_URL}login`, { nameOrEmail, - password, + password }) } export async function Self(): Promise> { return QueryPostAPI(`${ACCOUNT_API_URL}self`) } -export async function AddBiliBlackList(id: number, name: string): Promise> { +export async function AddBiliBlackList( + id: number, + name: string +): Promise> { return QueryGetAPI(`${ACCOUNT_API_URL}black-list/add-bili`, { id: id, - name: name, + name: name }) } export async function DelBiliBlackList(id: number): Promise> { return QueryGetAPI(`${ACCOUNT_API_URL}black-list/del-bili`, { - id: id, + id: id }) } export async function DelBlackList(id: number): Promise> { return QueryGetAPI(`${ACCOUNT_API_URL}black-list/del`, { - id: id, + id: id }) } export function downloadConfigDirect(name: string) { return QueryGetAPI(VTSURU_API_URL + 'get-config', { - name: name, + name: name }) } export async function DownloadConfig(name: string) { try { const resp = await QueryGetAPI(VTSURU_API_URL + 'get-config', { - name: name, + name: name }) if (resp.code == 200) { console.log('已获取配置文件: ' + name) return { msg: undefined, - data: JSON.parse(resp.data) as T, + data: JSON.parse(resp.data) as T } } else if (resp.code == 404) { console.error(`未找到名为 ${name} 的配置文件`) return { msg: `未找到名为 ${name} 的配置文件, 需要先上传`, - data: undefined, + data: undefined } } else { console.error(`无法获取配置文件 [${name}]: ` + resp.message) return { msg: `无法获取配置文件 [${name}]: ` + resp.message, - data: undefined, + data: undefined } } } catch (err) { console.error(`无法获取配置文件 [${name}]: ` + err) return { msg: `无法获取配置文件 [${name}]: ` + err, - data: undefined, + data: undefined } } } @@ -186,7 +218,7 @@ export async function UploadConfig(name: string, data: unknown) { try { const resp = await QueryPostAPI(VTSURU_API_URL + 'set-config', { name: name, - json: JSON.stringify(data), + json: JSON.stringify(data) }) if (resp.code == 200) { console.log('已保存配置文件至服务器:' + name) @@ -208,7 +240,10 @@ export async function EnableFunction(func: FunctionTypes) { if (await updateFunctionEnable()) { return true } else { - ACCOUNT.value.settings.enableFunctions.splice(ACCOUNT.value.settings.enableFunctions.indexOf(func), 1) + ACCOUNT.value.settings.enableFunctions.splice( + ACCOUNT.value.settings.enableFunctions.indexOf(func), + 1 + ) return false } } @@ -220,7 +255,10 @@ export async function DisableFunction(func: FunctionTypes) { if (!ACCOUNT.value.settings.enableFunctions.includes(func)) { return true } else { - ACCOUNT.value.settings.enableFunctions.splice(ACCOUNT.value.settings.enableFunctions.indexOf(func), 1) + ACCOUNT.value.settings.enableFunctions.splice( + ACCOUNT.value.settings.enableFunctions.indexOf(func), + 1 + ) if (await updateFunctionEnable()) { return true } else { @@ -234,7 +272,9 @@ export async function DisableFunction(func: FunctionTypes) { async function updateFunctionEnable() { if (ACCOUNT.value) { try { - const data = await SaveEnableFunctions(ACCOUNT.value.settings.enableFunctions) + const data = await SaveEnableFunctions( + ACCOUNT.value.settings.enableFunctions + ) if (data.code == 200) { return true } else { diff --git a/src/data/RTCClient.ts b/src/data/RTCClient.ts new file mode 100644 index 0000000..a1cc30a --- /dev/null +++ b/src/data/RTCClient.ts @@ -0,0 +1,192 @@ +import { useAccount } from '@/api/account' +import { useVTsuruHub } from '@/store/useVTsuruHub' +import Peer, { DataConnection } from 'peerjs' +import { Ref, ref } from 'vue' + +export interface ComponentsEventHubModel { + IsMaster: boolean + Token: string +} +export interface RTCData { + Key: string + Data: any +} + +abstract class BaseRTCClient { + constructor(user: string, pass: string) { + this.user = user + this.pass = pass + } + + protected user: string + protected pass: string + protected vhub = useVTsuruHub() + + public isInited = false + + public peer?: Peer + + protected connections: DataConnection[] = [] + + protected events: { + [key: string]: ((args: unknown) => void)[] + } = {} + + abstract type: 'master' | 'slave' + + public on(eventName: string, listener: (args: unknown) => void) { + eventName = eventName.toLowerCase() + if (!this.events[eventName]) { + this.events[eventName] = [] + } + this.events[eventName].push(listener) + } + public off(eventName: string, listener: (args: unknown) => void) { + if (this.events[eventName]) { + const index = this.events[eventName].indexOf(listener) + if (index > -1) { + this.events[eventName].splice(index, 1) + } + } + } + public send(eventName: string, data: unknown) { + this.connections.forEach((item) => + item.send({ + Key: eventName, + Data: data + }) + ) + } + + protected connectRTC() { + //console.log('[Components-Event] 正在连接到 PeerJS 服务器...') + this.peer = new Peer({ + host: 'peer.suki.club', + port: 443, + key: 'vtsuru', + secure: true, + config: { + iceServers: [ + { urls: 'stun:turn.suki.club' }, + { + urls: 'turn:turn.suki.club', + username: this.user, + credential: this.pass + } + ] + } + //debug: 3 + }) + + this.peer?.on('open', async (id) => { + console.log('[Components-Event] 已连接到 PeerJS 服务器: ' + id) + this.vhub?.send('SetRTCToken', id, this.type == 'master') + }) + this.peer?.on('error', (err) => { + console.error(err) + }) + this.peer?.on('close', () => { + console.log('[Components-Event] PeerJS 连接已关闭') + }) + this.peer?.on('disconnected', () => { + console.log('[Components-Event] PeerJS 连接已断开') + this.peer?.reconnect() + }) + } + public processData(data: RTCData) { + //console.log(data) + if (data.Key == 'Heartbeat') return + if (this.events[data.Key.toLowerCase()]) { + this.events[data.Key].forEach((item) => item(data.Data)) + } + } + public async getAllRTC() { + return ( + (await this.vhub.invoke('GetOnlineRTC')) || [] + ) + } + protected onConnectionClose(id: string) { + this.connections = this.connections.filter((item) => item.peer != id) + console.log( + `[Components-Event] <${this.connections.length}> ${this.type == 'master' ? 'Slave' : 'Master'} 下线: ` + + id + ) + } + + public Init() { + if (!this.isInited) { + this.isInited = true + this.connectRTC() + } + this.vhub.on('RTCOffline', (id: string) => this.onConnectionClose(id)) + return this + } +} + +export class SlaveRTCClient extends BaseRTCClient { + constructor(user: string, pass: string) { + super(user, pass) + } + type: 'slave' = 'slave' as const + public async connectToAllMaster() { + const masters = (await this.getAllRTC()).filter( + (item) => + item.IsMaster && + item.Token != this.peer!.id && + !this.connections.some((conn) => conn.peer == item.Token) + ) + masters.forEach((item) => { + this.connectToMaster(item.Token) + //console.log('[Components-Event] 正在连接到现有 Master: ' + item.Token) + }) + } + public connectToMaster(token: string) { + if (this.connections.some((conn) => conn.peer == token)) return + const c = this.peer?.connect(token) + c?.on('open', () => { + this.connections.push(c) + console.log( + `[Components-Event] <${this.connections.length}> ==> Master 连接已建立: ` + + token + ) + }) + c?.on('data', (data) => this.processData(data as RTCData)) + c?.on('close', () => this.onConnectionClose(c.peer)) + } + public Init() { + super.Init() + this.vhub?.on('MasterOnline', (data: string) => this.connectToMaster(data)) + setTimeout(() => { + this.connectToAllMaster() + }, 500) + setInterval(() => { + this.connectToAllMaster() + }, 30000) + + return this + } +} +export class MasterRTCClient extends BaseRTCClient { + constructor(user: string, pass: string) { + super(user, pass) + } + type: 'master' = 'master' as const + public connectRTC() { + super.connectRTC() + this.peer?.on('connection', (conn) => { + conn.on('open', () => { + this.connections.push(conn) + console.log( + `[Components-Event] <${this.connections.length}> Slave 上线: ` + + conn.peer + ) + }) + conn.on('data', (data) => this.processData(data as RTCData)) + conn.on('error', (err) => console.error(err)) + conn.on('close', () => this.onConnectionClose(conn.peer)) + }) + } + public Init() { + return super.Init() + } +} diff --git a/src/router/index.ts b/src/router/index.ts index b174d02..076ffdb 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -97,7 +97,7 @@ const routes: Array = [ { path: '/:pathMatch(.*)*', name: 'notfound', - component: import('@/views/NotfoundView.vue'), + component: () => import('@/views/NotfoundView.vue'), meta: { title: '页面不存在', }, diff --git a/src/router/singlePage.ts b/src/router/singlePage.ts index 97c37f7..f32e4a0 100644 --- a/src/router/singlePage.ts +++ b/src/router/singlePage.ts @@ -4,7 +4,15 @@ export default [ name: 'question-display', component: () => import('@/views/single/QuestionDisplay.vue'), meta: { - title: '棉花糖展示页', - }, + title: '棉花糖展示页' + } }, + { + path: '/playground/test', + name: 'test', + component: () => import('@/views/TestView.vue'), + meta: { + title: '测试页' + } + } ] diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts index 27879be..3212ba0 100644 --- a/src/store/useAuthStore.ts +++ b/src/store/useAuthStore.ts @@ -36,6 +36,7 @@ export const useAuthStore = defineStore('BiliAuth', () => { async function getAuthInfo() { try { isLoading.value = true + if(!currentToken.value) return await QueryBiliAuthGetAPI(BILI_AUTH_API_URL + 'info').then((data) => { if (data.code == 200) { biliAuth.value = data.data diff --git a/src/store/useRTC.ts b/src/store/useRTC.ts new file mode 100644 index 0000000..808fa5f --- /dev/null +++ b/src/store/useRTC.ts @@ -0,0 +1,44 @@ +import { useAccount } from '@/api/account' +import { MasterRTCClient, SlaveRTCClient } from '@/data/RTCClient' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { ref } from 'vue' + +export const useWebRTC = defineStore('WebRTC', () => { + const masterClient = ref() + const slaveClient = ref() + const accountInfo = useAccount() + + function Init(type: 'master' | 'slave') { + if (type == 'master') { + if (masterClient.value) { + return masterClient + } else { + masterClient.value = new MasterRTCClient( + accountInfo.value.id.toString(), + accountInfo.value.token + ) + masterClient.value.Init() + return masterClient + } + } else { + if (slaveClient.value) { + return slaveClient + } else { + slaveClient.value = new SlaveRTCClient( + accountInfo.value.id.toString(), + accountInfo.value.token + ) + slaveClient.value.Init() + return slaveClient + } + } + } + + return { + Init + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useWebRTC, import.meta.hot)) +} diff --git a/src/store/useVTsuruHub.ts b/src/store/useVTsuruHub.ts new file mode 100644 index 0000000..66afa7e --- /dev/null +++ b/src/store/useVTsuruHub.ts @@ -0,0 +1,106 @@ +import { useAccount } from '@/api/account' +import { BASE_HUB_URL } from '@/data/constants' +import { + HttpTransportType, + HubConnectionBuilder, + LogLevel +} from '@microsoft/signalr' +import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { ref } from 'vue' + +export const useVTsuruHub = defineStore('VTsuruHub', () => { + const accountInfo = useAccount() + const signalRClient = ref() + const isInited = ref(false) + const isIniting = ref(false) + + async function connectSignalR() { + if (isIniting.value) return + isIniting.value = true + //console.log('[Components-Event] 正在连接到 VTsuru 服务器...') + const connection = new HubConnectionBuilder() + .withUrl(BASE_HUB_URL + 'main?token=' + accountInfo.value.token, { + skipNegotiation: true, + transport: HttpTransportType.WebSockets, + logger: LogLevel.Error + }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .withHubProtocol(new MessagePackHubProtocol()) + .build() + connection.on('Finished', async () => { + connection.send('Finished') + }) + connection.on('Disconnect', (reason: unknown) => { + console.log('[Hub] 被 VTsuru 服务器断开连接: ' + reason) + }) + + connection.onclose(reconnect) + try { + await connection.start() + console.log('[Hub] 已连接到 VTsuru 服务器') + signalRClient.value = connection + isInited.value = true + return true + } catch (e) { + console.log('[Hub] 无法连接到 VTsuru 服务器: ' + e) + return false + } finally { + isIniting.value = false + } + } + async function reconnect() { + try { + await signalRClient.value?.start() + signalRClient.value?.send('Reconnected') + console.log('[Hub] 已重新连接') + } catch (err) { + console.log(err) + setTimeout(reconnect, 5000) // 如果连接失败,则每5秒尝试一次重新启动连接 + } + } + + async function send(methodName: string, ...args: any[]) { + if (!isInited.value) { + await connectSignalR() + } + signalRClient.value?.send(methodName, ...args) + } + async function invoke(methodName: string, ...args: any[]) { + if (!isInited.value) { + await connectSignalR() + } + return signalRClient.value?.invoke(methodName, ...args) + } + async function on(eventName: string, listener: (args: any) => any) { + if (!isInited.value) { + await connectSignalR() + } + signalRClient.value?.on(eventName, listener) + } + async function off(eventName: string, listener: (args: any) => any) { + if (!isInited.value) { + await connectSignalR() + } + signalRClient.value?.off(eventName, listener) + } + async function onreconnected(listener: (id: any) => any) { + if (!isInited.value) { + await connectSignalR() + } + signalRClient.value?.onreconnected(listener) + } + + function Init() { + if (!isInited.value) { + connectSignalR() + } + return useVTsuruHub() + } + + return { signalRClient, Init, send, invoke, on, off, onreconnected } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useVTsuruHub, import.meta.hot)) +} diff --git a/src/views/TestView.vue b/src/views/TestView.vue new file mode 100644 index 0000000..8c38a58 --- /dev/null +++ b/src/views/TestView.vue @@ -0,0 +1,36 @@ + + + \ No newline at end of file diff --git a/src/views/manage/DashboardView.vue b/src/views/manage/DashboardView.vue index 0d81f4b..997258b 100644 --- a/src/views/manage/DashboardView.vue +++ b/src/views/manage/DashboardView.vue @@ -486,6 +486,8 @@ onUnmounted(() => { + + - diff --git a/src/views/manage/Setting_PaymentView.vue b/src/views/manage/Setting_PaymentView.vue index 4c3f880..a32ef8a 100644 --- a/src/views/manage/Setting_PaymentView.vue +++ b/src/views/manage/Setting_PaymentView.vue @@ -36,7 +36,10 @@ onMounted(() => {