feat: 更新项目配置和依赖,增强功能和用户体验

- 完成弹幕机功能
- 在 .editorconfig 中新增对 vine.ts 文件的支持。
- 更新 package.json 中多个依赖的版本,提升稳定性和性能。
- 在 vite.config.mts 中引入 @guolao/vue-monaco-editor 插件,增强代码编辑功能。
- 在 App.vue 中调整内容填充的样式,优化界面布局。
- 新增获取配置文件哈希的 API 方法,提升配置管理能力。
- 在多个组件中优化了样式和逻辑,提升用户交互体验。
This commit is contained in:
2025-04-25 00:08:06 +08:00
parent b24974540f
commit 07948e6777
36 changed files with 3108 additions and 1258 deletions

View File

@@ -1,4 +1,4 @@
[*.{js,jsx,ts,tsx,vue}]
[*.{js,jsx,ts,tsx,vue,vine.ts}]
indent_style = space
indent_size = 2
end_of_line = lf

BIN
bun.lockb

Binary file not shown.

View File

@@ -11,32 +11,33 @@
},
"dependencies": {
"@antfu/ni": "^24.3.0",
"@guolao/vue-monaco-editor": "^1.5.5",
"@hyperdx/browser": "^0.21.2",
"@hyperdx/cli": "^0.1.0",
"@microsoft/signalr": "^8.0.7",
"@microsoft/signalr-protocol-msgpack": "^8.0.7",
"@mixer/postmessage-rpc": "^1.1.4",
"@oneidentity/zstd-js": "^1.0.3",
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "^2.3.0",
"@tauri-apps/plugin-http": "^2.4.2",
"@tauri-apps/plugin-log": "^2.3.1",
"@tauri-apps/plugin-http": "^2.4.3",
"@tauri-apps/plugin-log": "^2.4.0",
"@tauri-apps/plugin-notification": "^2.2.2",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-store": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.7.0",
"@tauri-apps/plugin-updater": "^2.7.1",
"@types/crypto-js": "^4.2.2",
"@types/md5": "^2.3.5",
"@typescript-eslint/eslint-plugin": "^8.27.0",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@vicons/fluent": "^0.13.0",
"@vitejs/plugin-basic-ssl": "^2.0.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/cli": "^5.0.8",
"@vueuse/core": "^13.0.0",
"@vueuse/core": "^13.1.0",
"@vueuse/integrations": "^13.1.0",
"@vueuse/router": "^13.0.0",
"@vueuse/router": "^13.1.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"bilibili-live-ws": "^6.3.1",
@@ -46,32 +47,32 @@
"easy-speech": "^2.4.0",
"echarts": "^5.6.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-oxlint": "^0.16.2",
"eslint-plugin-prettier": "^5.2.4",
"fast-xml-parser": "^5.0.9",
"eslint-plugin-oxlint": "^0.16.7",
"eslint-plugin-prettier": "^5.2.6",
"fast-xml-parser": "^5.2.1",
"file-saver": "^2.0.5",
"grapheme-splitter": "^1.0.4",
"html2canvas": "^1.4.1",
"idb-keyval": "^6",
"idb-keyval": "^6.2.1",
"linqts": "^2.0.0",
"md5": "^2.3.0",
"mitt": "^3.0.1",
"monaco-editor": "^0.52.2",
"music-metadata-browser": "^2.5.11",
"nanoid": "^5.1.5",
"oxlint": "^0.16.2",
"oxlint": "^0.16.7",
"peerjs": "^1.5.4",
"pinia": "^3.0.1",
"pinia": "^3.0.2",
"prettier": "^3.5.3",
"qrcode.vue": "^3.6.0",
"queue-typescript": "^1.0.1",
"unplugin-auto-import": "^19.1.1",
"unplugin-vue-components": "^28.4.1",
"unplugin-auto-import": "^19.1.2",
"unplugin-vue-components": "^28.5.0",
"unplugin-vue-markdown": "^28.3.1",
"uuid": "^11.1.0",
"vite": "6.2.2",
"vite": "6.3.3",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-oxlint": "^1.3.0",
"vite-plugin-oxlint": "^1.3.1",
"vite-svg-loader": "^5.1.0",
"vue": "3.5.13",
"vue-echarts": "^7.0.3",
@@ -81,28 +82,28 @@
"vue3-aplayer": "^1.7.3",
"vue3-marquee": "^4.2.2",
"vueuc": "^0.4.64",
"worker-timers": "^8.0.19",
"worker-timers": "^8.0.20",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@types/bun": "^1.2.5",
"@types/bun": "^1.2.10",
"@types/eslint": "^9.6.1",
"@types/file-saver": "^2.0.7",
"@types/obs-studio": "^2.17.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/parser": "^8.27.0",
"@typescript-eslint/parser": "^8.31.0",
"@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"@vue-vine/eslint-config": "^0.2.19",
"@vue/eslint-config-typescript": "^14.5.0",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-vue": "^10.0.0",
"knip": "^5.50.4",
"knip": "^5.50.5",
"naive-ui": "^2.41.0",
"stylus": "^0.64.0",
"typescript": "^5.8.3",
"vue-vine": "^0.3.19"
"vue-vine": "^0.3.21"
}
}

View File

@@ -124,7 +124,7 @@
:root {
font-feature-settings: 'liga' 1, 'calt' 1;
--vtsuru-header-height: 50px;
--vtsuru-content-padding: 16px;
--vtsuru-content-padding: 12px;
}
@supports (font-variation-settings: normal) {

View File

@@ -254,6 +254,22 @@ export async function UploadConfig(name: string, data: unknown) {
}
return false;
}
export async function GetConfigHash(name: string) {
try {
const resp = await QueryGetAPI<string>(VTSURU_API_URL + 'get-config-hash', {
name: name
});
if (resp.code == 200) {
return resp.data;
} else {
console.error(`获取配置文件hash失败: ` + resp.message);
return null;
}
} catch (err) {
console.error(`获取配置文件hash失败: ` + err);
return null;
}
}
export async function EnableFunction(func: FunctionTypes) {
if (ACCOUNT.value) {
if (ACCOUNT.value.settings.enableFunctions.includes(func)) {

View File

@@ -717,6 +717,7 @@ export interface BiliAuthBaseModel {
export interface BiliAuthModel extends BiliAuthBaseModel {
address?: AddressInfo[]
token: string
guardInfo: Record<number, GuardLevel>
}
export interface ResponsePointUserModel {
point: number

View File

@@ -1,4 +1,6 @@
html:not(.style-scope) {
@layer yt {
html:not(.style-scope) {
--yt-live-chat-background-color: hsl(0, 0%, 100%);
--yt-live-chat-action-panel-background-color: hsla(0, 0%, 93.3%, .4);
--yt-live-chat-action-panel-background-color-transparent: hsla(0, 0%, 97%, .8);
@@ -29,7 +31,7 @@ html:not(.style-scope) {
--yt-live-chat-reconnect-message-color: hsla(0, 0%, 7%, 0.2);
--yt-live-chat-moderator-color: hsl(225, 84%, 66%);
--yt-live-chat-owner-color: hsl(40, 76%, 55%);
--yt-live-chat-author-chip-owner-text-color: rgba(0,0,0,0.87);
--yt-live-chat-author-chip-owner-text-color: rgba(0, 0, 0, 0.87);
--yt-live-chat-author-chip-verified-background-color: #CCCCCC;
--yt-live-chat-author-chip-verified-text-color: #606060;
--yt-live-chat-message-highlight-background-color: #f8f8f8;
@@ -77,9 +79,9 @@ html:not(.style-scope) {
--yt-pdg-paid-stickers-tab-selection-bar-color: #065FD4;
--yt-pdg-paid-stickers-author-name-font-size: 13px;
--yt-pdg-paid-stickers-margin-left: 38px;
}
}
html:not(.style-scope) {
html:not(.style-scope) {
--layout_-_display: flex;
;
@@ -359,4 +361,6 @@ html:not(.style-scope) {
--layout-fixed-left_-_bottom: 0;
--layout-fixed-left_-_left: 0;
;
}
}

View File

@@ -508,8 +508,26 @@ onMounted(() => {
v-for="(label, type) in typeMap"
:key="type"
:name="type"
:tab="label"
>
<template #tab>
<NSpace
align="center"
size="small"
inline
>
{{ label }}
<span
:style="{
color: enabledTriggerTypes && enabledTriggerTypes[type] ? '#18a058' : '#d03050',
fontSize: '14px', // Adjust size as needed
verticalAlign: 'middle'
}"
:title="enabledTriggerTypes && enabledTriggerTypes[type] ? '已启用' : '已禁用'"
>
</span>
</NSpace>
</template>
<NSpace vertical>
<NSpace
v-if="enabledTriggerTypes"

View File

@@ -30,6 +30,7 @@ export function filterValidActions(
options?: {
actionType?: ActionType; // 特定操作类型
customFilter?: (action: AutoActionItem) => boolean; // 自定义过滤器
enabledTriggerTypes?: Ref<Record<TriggerType, boolean>> // 触发类型启用状态
}
): AutoActionItem[] {
return actions.filter(action => {
@@ -38,6 +39,11 @@ export function filterValidActions(
return false;
}
// 检查触发类型是否启用
if (options?.enabledTriggerTypes && !options.enabledTriggerTypes.value[triggerType]) {
return false;
}
// 直播状态过滤
if (action.triggerConfig.onlyDuringLive && !isLive.value) {
return false;
@@ -146,6 +152,38 @@ export function processTemplate(
}
}
// 辅助函数:发送弹幕并记录日志
async function sendAndLogDanmaku(
sendHandler: (roomId: number, message: string) => Promise<boolean>,
action: AutoActionItem,
roomId: number,
message: string
): Promise<boolean> {
try {
const success = await sendHandler(roomId, message);
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
success,
success ? undefined : '发送失败'
).catch(err => console.error('记录弹幕历史失败:', err));
return success;
} catch (err) {
console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err);
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
false,
err instanceof Error ? err.toString() : String(err) // 确保err是字符串
).catch(e => console.error('记录弹幕历史失败:', e));
return false;
}
}
/**
* 执行操作的通用函数
* @param actions 过滤后的操作列表
@@ -220,61 +258,13 @@ export function executeActions(
// 更新冷却时间
runtimeState.lastExecutionTime[action.id] = Date.now();
const sendAction = () => sendAndLogDanmaku(handlers.sendLiveDanmaku!, action, roomId, message);
// 延迟发送
if (action.actionConfig.delaySeconds && action.actionConfig.delaySeconds > 0) {
setTimeout(() => {
handlers.sendLiveDanmaku!(roomId, message)
.then(success => {
// 记录弹幕发送历史
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
success,
success ? undefined : '发送失败'
).catch(err => console.error('记录弹幕历史失败:', err));
return success;
})
.catch(err => {
console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err);
// 记录失败的发送
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
false,
err.toString()
).catch(e => console.error('记录弹幕历史失败:', e));
});
}, action.actionConfig.delaySeconds * 1000);
setTimeout(sendAction, action.actionConfig.delaySeconds * 1000);
} else {
handlers.sendLiveDanmaku(roomId, message)
.then(success => {
// 记录弹幕发送历史
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
success,
success ? undefined : '发送失败'
).catch(err => console.error('记录弹幕历史失败:', err));
return success;
})
.catch(err => {
console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err);
// 记录失败的发送
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
false,
err.toString()
).catch(e => console.error('记录弹幕历史失败:', e));
});
sendAction();
}
}
} else {
@@ -321,7 +311,7 @@ export function executeActions(
msg,
uid,
false,
err.toString()
err instanceof Error ? err.toString() : String(err) // 确保err是字符串
).catch(e => console.error('记录私信历史失败:', e));
return false; // 明确返回 false 表示失败
});

View File

@@ -28,19 +28,21 @@ import {
createDefaultRuntimeState
} from './autoAction/utils';
import { evaluateTemplateExpressions } from './autoAction/expressionEvaluator';
// 导入 actionUtils 工具函数
import { filterValidActions, checkUserFilters, checkCooldown, processTemplate, executeActions } from './autoAction/actionUtils';
// 导入 nanoid 用于生成唯一 ID
import { nanoid } from 'nanoid';
// 导入开发环境判断标志
import { isDev } from '@/data/constants.js';
// 导入所有自动操作子模块
import { useGiftThank } from './autoAction/modules/giftThank.js';
import { useGuardPm } from './autoAction/modules/guardPm.js';
import { useFollowThank } from './autoAction/modules/followThank.js';
import { useEntryWelcome } from './autoAction/modules/entryWelcome.js';
import { useAutoReply } from './autoAction/modules/autoReply.js';
import { useScheduledDanmaku } from './autoAction/modules/scheduledDanmaku.js';
import { useSuperChatThank } from './autoAction/modules/superChatThank.js';
import { useGiftThank } from './autoAction/modules/giftThank';
import { useGuardPm } from './autoAction/modules/guardPm';
import { useFollowThank } from './autoAction/modules/followThank';
import { useEntryWelcome } from './autoAction/modules/entryWelcome';
import { useAutoReply } from './autoAction/modules/autoReply';
import { useScheduledDanmaku } from './autoAction/modules/scheduledDanmaku';
import { useSuperChatThank } from './autoAction/modules/superChatThank';
// 定义名为 'autoAction' 的 Pinia store
export const useAutoAction = defineStore('autoAction', () => {
@@ -540,38 +542,6 @@ export const useAutoAction = defineStore('autoAction', () => {
// 定时检查天选状态 (每5分钟)
const tianXuanTimer = setInterval(checkTianXuanStatus, 5 * 60 * 1000);
/**
* 判断是否应处理某个操作项 (基于事件和配置)
* @param action 操作项配置
* @param event 可选的事件数据
* @returns 是否应该处理
*/
function shouldProcessAction(action: AutoActionItem, event?: EventModel | null): boolean {
if (!action.enabled) return false; // 未启用则跳过
if (!enabledTriggerTypes.value[action.triggerType]) return false; // 触发类型未启用则跳过
// 检查模板是否为空 (添加新的检查)
if (!action.template || action.template.trim() === '') {
console.warn(`[AutoAction] 跳过操作 "${action.name}":未设置有效模板`);
return false;
}
// 根据配置检查条件
if (action.triggerConfig.onlyDuringLive && !isLive.value) return false; // 仅直播时
if (action.triggerConfig.ignoreTianXuan && isTianXuanActive.value) return false; // 忽略天选时
// 用户过滤条件
if (event && action.triggerConfig.userFilterEnabled) {
if (action.triggerConfig.requireMedal && !event.fans_medal_wearing_status) return false; // 要求粉丝牌
if (action.triggerConfig.requireCaptain && event.guard_level === GuardLevel.None) return false; // 要求舰长
}
// 逻辑表达式判断
if (action.logicalExpression && event) {
const context = buildExecutionContext(event, roomId.value, action.triggerType);
if (!evaluateExpression(action.logicalExpression, context)) return false; // 表达式不满足
}
return true; // 所有条件满足
}
/**
* 处理接收到的事件
* @param event 事件数据
@@ -579,10 +549,8 @@ export const useAutoAction = defineStore('autoAction', () => {
*/
function processEvent(event: EventModel, triggerType: TriggerType) {
if (!roomId.value) return; // 房间 ID 无效则跳过
// 检查触发类型是否启用
if (!enabledTriggerTypes.value[triggerType]) return;
// 根据触发类型调用相应模块的处理函数
switch (triggerType) {
case TriggerType.DANMAKU:

View File

@@ -2,12 +2,13 @@
<div
ref="editorContainer"
:style="`height: ${height}px;`"
/>
</template>
<script setup lang="ts">
import { editor } from 'monaco-editor'; // 全部导入
import { ref, onMounted } from 'vue';
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
const value = defineModel<string>('value')
@@ -16,10 +17,13 @@ const { language, height = 400 } = defineProps<{
height?: number
}>()
const editorContainer = ref()
const editorContainer = ref<HTMLElement>()
let editorInstance: editor.IStandaloneCodeEditor | null = null
onMounted(() => {
const e = editor.create(editorContainer.value, {
if (!editorContainer.value) return
editorInstance = editor.create(editorContainer.value, {
value: value.value,
language: language,
minimap: {
@@ -28,8 +32,36 @@ onMounted(() => {
colorDecorators: true,
automaticLayout: true
})
e.onDidChangeModelContent(() => {
value.value = e.getValue()
editorInstance.onDidChangeModelContent(() => {
if (editorInstance) {
const currentValue = editorInstance.getValue()
if (currentValue !== value.value) {
value.value = currentValue
}
}
})
})
onBeforeUnmount(() => {
if (editorInstance) {
editorInstance.dispose()
editorInstance = null
}
})
watch(value, (newValue) => {
if (editorInstance && newValue !== editorInstance.getValue()) {
editorInstance.setValue(newValue ?? '')
}
})
watch(() => language, (newLang) => {
if (editorInstance) {
const model = editorInstance.getModel()
if (model) {
editor.setModelLanguage(model, newLang)
}
}
})
</script>

View File

@@ -167,9 +167,7 @@ function onLoginButtonClick() {
refreshDate: Date.now()
}
message.success(`成功登陆为 ${data?.data.account.name}`)
setTimeout(() => {
location.reload()
}, 1000)
} else {
message.error(data.message)
}

View File

@@ -95,12 +95,8 @@ function InitVersionCheck() {
localStorage.setItem('Version', currentVersion)
console.log(`[vtsuru] 发现新版本: ${currentVersion}`)
const url = new URL(window.location.href)
const path = url.pathname
if (!path.startsWith('/obs')) {
if (isTauri()) {
location.reload();
if (window.$route.meta.forceReload || isTauri()) {
location.reload()
}
else {
const n = notification.info({
@@ -132,7 +128,6 @@ function InitVersionCheck() {
})
}
}
}
},
)
}, 60 * 1000)

View File

@@ -4,6 +4,26 @@ import { VNode } from "vue";
import { FETCH_API } from "./constants";
export const updateNotes: updateNoteType[] = [
{
ver: 5,
date: '2025.4.24',
items: [
{
type: 'new',
title: '新增弹幕姬管理页面',
content: [
[
'弹幕姬现在可用,兼容 blivechat 样式',
() => h(NImage, { src: 'https://pan.suki.club/d/vtsuru/imgur/3c5a6f68-1aa4-4b96-a25e-dba2581ac898.png', width: 300 }),
],
[
'大部分功能都和 blivechat 一致, 不过目前还无法提供本地文件访问, 部分css中需要使用图片等本地资源样式的需要等 EventFetcher 更新相关功能后才能使用\r\n',
'配置上传之后会自动同步到obs中'
]
],
},
]
},
{
ver: 4,
date: '2025.4.22',

View File

@@ -118,3 +118,301 @@ export const IndexTemplateMap: TemplateMapType = {
component: DefaultIndexTemplateVue
}
};
export const defaultDanmujiCss = `@import url("https://fonts.googleapis.com/css?family=Changa%20One");
@import url("https://fonts.googleapis.com/css?family=Imprima");
/* Transparent background */
yt-live-chat-renderer {
background-color: transparent !important;
}
yt-live-chat-ticker-renderer {
background-color: transparent !important;
box-shadow: none !important;
}
yt-live-chat-author-chip #author-name {
background-color: transparent !important;
}
yt-live-chat-item-list-renderer #item-scroller {
overflow: hidden !important;
}
yt-live-chat-interact-message-renderer #content,
yt-live-chat-text-message-renderer #content,
yt-live-chat-membership-item-renderer #content {
overflow: visible !important;
}
/* Hide header and input */
yt-live-chat-header-renderer,
yt-live-chat-message-input-renderer {
display: none !important;
}
/* Hide unimportant messages */
yt-live-chat-interact-message-renderer[is-deleted],
yt-live-chat-text-message-renderer[is-deleted],
yt-live-chat-membership-item-renderer[is-deleted] {
display: none !important;
}
yt-live-chat-mode-change-message-renderer,
yt-live-chat-viewer-engagement-message-renderer,
yt-live-chat-restricted-participation-renderer {
display: none !important;
}
yt-live-chat-text-message-renderer a,
yt-live-chat-membership-item-renderer a {
text-decoration: none !important;
}
/* Global Setting */
yt-live-chat-renderer {
}
#item-scroller {
}
/* Reduce side padding */
yt-live-chat-interact-message-renderer,
yt-live-chat-text-message-renderer {
padding-left: 4px !important;
padding-right: 4px !important;
}
/* Outlines */
yt-live-chat-renderer * {
text-shadow: -2px -2px #000000, -2px -1px #000000, -2px 0px #000000, -2px 1px #000000, -2px 2px #000000, -1px -2px #000000, -1px -1px #000000, -1px 0px #000000, -1px 1px #000000, -1px 2px #000000, 0px -2px #000000, 0px -1px #000000, 0px 0px #000000, 0px 1px #000000, 0px 2px #000000, 1px -2px #000000, 1px -1px #000000, 1px 0px #000000, 1px 1px #000000, 1px 2px #000000, 2px -2px #000000, 2px -1px #000000, 2px 0px #000000, 2px 1px #000000, 2px 2px #000000;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 18px !important;
line-height: 20px !important;
}
/* Avatars */
yt-live-chat-interact-message-renderer #author-photo,
yt-live-chat-interact-message-renderer #author-photo img,
yt-live-chat-text-message-renderer #author-photo,
yt-live-chat-text-message-renderer #author-photo img,
yt-live-chat-paid-message-renderer #author-photo,
yt-live-chat-paid-message-renderer #author-photo img,
yt-live-chat-membership-item-renderer #author-photo,
yt-live-chat-membership-item-renderer #author-photo img {
width: 24px !important;
height: 24px !important;
border-radius: 24px !important;
margin-right: 6px !important;
}
/* Channel names */
yt-live-chat-interact-message-renderer #content #author-name,
yt-live-chat-text-message-renderer #content #author-name {
}
yt-live-chat-interact-message-renderer #author-name[type="owner"],
yt-live-chat-interact-message-renderer yt-live-chat-author-badge-renderer[type="owner"],
yt-live-chat-text-message-renderer #author-name[type="owner"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="owner"] {
color: #ffd600 !important;
}
yt-live-chat-interact-message-renderer #author-name[type="moderator"],
yt-live-chat-interact-message-renderer yt-live-chat-author-badge-renderer[type="moderator"],
yt-live-chat-text-message-renderer #author-name[type="moderator"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="moderator"] {
color: #5e84f1 !important;
}
yt-live-chat-interact-message-renderer #author-name[type="member"],
yt-live-chat-interact-message-renderer yt-live-chat-author-badge-renderer[type="member"],
yt-live-chat-text-message-renderer #author-name[type="member"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="member"] {
color: #0f9d58 !important;
}
yt-live-chat-interact-message-renderer #author-name,
yt-live-chat-text-message-renderer #author-name {
color: #cccccc !important;
font-family: "Changa One", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 20px !important;
line-height: 20px !important;
}
/* Show colon */
yt-live-chat-text-message-renderer #author-name::after {
content: ":";
margin-left: 2px;
}
/* Hide badges */
yt-live-chat-interact-message-renderer #chat-badges,
yt-live-chat-text-message-renderer #chat-badges {
vertical-align: text-top !important;
}
img.yt-live-chat-author-badge-renderer, yt-icon.yt-live-chat-author-badge-renderer {
width: 20px;
height: 20px;
}
/* Medal */
yt-live-chat-author-medal-renderer {
display: none;
}
yt-live-chat-author-medal-renderer[is-fan-group] {
display: flex;
}
#medal-name.yt-live-chat-author-medal-renderer {
font-size: 14px !important;
line-height: 14px !important;
}
#medal-level.yt-live-chat-author-medal-renderer {
font-size: 14px !important;
line-height: 14px !important;
}
/* Messages */
yt-live-chat-interact-message-renderer #message,
yt-live-chat-interact-message-renderer #message *,
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
color: #ffffff !important;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-text-message-renderer #image-and-message {
display: inline !important;
overflow: visible !important;
}
yt-live-chat-text-message-renderer #message {
display: inline !important;
overflow: visible !important;
}
yt-live-chat-text-message-renderer #image-and-message .emoji {
width: auto !important;
height: 48px !important;
}
#image-and-message img[display="block"] {
border-radius: 4px;
}
#image-and-message img[display="inline"] {
position: relative;
top: 3px;
border-radius: 0px;
}
/* Timestamps */
/* Background colors */
body {
overflow: hidden;
background-color: rgba(0, 0, 0, 0);
}
yt-live-chat-text-message-renderer,
yt-live-chat-text-message-renderer[is-highlighted] {
background-color: rgba(204, 204, 204, 0) !important;
}
yt-live-chat-text-message-renderer[author-type="owner"],
yt-live-chat-text-message-renderer[author-type="owner"][is-highlighted] {
background-color: rgba(255, 214, 0, 0) !important;
}
yt-live-chat-text-message-renderer[author-type="moderator"],
yt-live-chat-text-message-renderer[author-type="moderator"][is-highlighted] {
background-color: rgba(94, 132, 241, 0) !important;
}
yt-live-chat-text-message-renderer[author-type="member"],
yt-live-chat-text-message-renderer[author-type="member"][is-highlighted] {
background-color: rgba(15, 157, 88, 0) !important;
}
/* SuperChat/Fan Funding Messages */
yt-live-chat-paid-message-renderer {
margin: 4px 0 !important;
}
yt-live-chat-paid-message-renderer #author-name,
yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-membership-item-renderer #header-content-inner-column,
yt-live-chat-membership-item-renderer #header-content-inner-column * {
color: #ffffff !important;
font-family: "Changa One", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 20px !important;
line-height: 20px !important;
}
yt-live-chat-paid-message-renderer #purchase-amount,
yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-membership-item-renderer #header-subtext,
yt-live-chat-membership-item-renderer #header-subtext * {
color: #ffffff !important;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
color: #ffffff !important;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-membership-item-renderer #card,
yt-live-chat-membership-item-renderer #header {
background-color: #0f9d58 !important;
margin: 4px 0 !important;
}
yt-live-chat-ticker-renderer {
display: none !important;
}
/* SuperChat Ticker */
yt-live-chat-ticker-paid-message-item-renderer,
yt-live-chat-ticker-paid-message-item-renderer *,
yt-live-chat-ticker-sponsor-item-renderer,
yt-live-chat-ticker-sponsor-item-renderer * {
color: #ffffff !important;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
}
/* Animation */
@keyframes anim {
}
yt-live-chat-interact-message-renderer,
yt-live-chat-text-message-renderer,
yt-live-chat-membership-item-renderer,
yt-live-chat-paid-message-renderer {
animation: anim 0ms;
animation-fill-mode: both;
}
`

View File

@@ -5,6 +5,18 @@ import App from './App.vue';
import { InitVTsuru } from './data/Initializer';
import emitter from './mitt';
import router from './router';
import { loader } from '@guolao/vue-monaco-editor'
loader.config({
paths: {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor/min/vs'
},
'vs/nls': {
availableLanguages: {
'*': 'zh-cn'
}
}
})
const pinia = createPinia()
export const getPinia = () => pinia

View File

@@ -8,6 +8,7 @@ export default {
component: () => import('@/client/ClientIndex.vue'),
meta: {
title: '首页',
forceReload: true,
}
},
{
@@ -16,6 +17,7 @@ export default {
component: () => import('@/client/ClientFetcher.vue'),
meta: {
title: 'EventFetcher',
forceReload: true,
}
},
{
@@ -24,6 +26,7 @@ export default {
component: () => import('@/client/ClientSettings.vue'),
meta: {
title: '设置',
forceReload: true,
}
},
{
@@ -32,6 +35,7 @@ export default {
component: () => import('@/client/DanmakuWindowManager.vue'),
meta: {
title: '弹幕窗口管理',
forceReload: true,
}
},
{
@@ -40,6 +44,7 @@ export default {
component: () => import('@/client/ClientAutoAction.vue'),
meta: {
title: '自动操作管理',
forceReload: true,
}
},
{
@@ -55,6 +60,7 @@ export default {
component: () => import('@/client/ClientTest.vue'),
meta: {
title: '测试',
forceReload: true,
}
},
]

View File

@@ -138,7 +138,7 @@ export default //管理页面
name: 'manage-danmuji',
component: () => import('@/views/manage/DanmujiManageView.vue'),
meta: {
title: '点歌',
title: '弹幕姬',
keepAlive: true,
danmaku: true,
isNew: true

View File

@@ -7,7 +7,8 @@ export default {
name: 'obs-live-lottery',
component: () => import('@/views/obs/LiveLotteryOBS.vue'),
meta: {
title: '直播抽奖'
title: '直播抽奖',
forceReload: true,
}
},
{
@@ -16,7 +17,8 @@ export default {
alias: 'song-request',
component: () => import('@/views/obs/LiveRequestOBS.vue'),
meta: {
title: '弹幕点播'
title: '弹幕点播',
forceReload: true,
}
},
{
@@ -24,7 +26,8 @@ export default {
name: 'obs-live-request-today',
component: () => import('@/views/obs/LiveRequestProcessedOBS.vue'),
meta: {
title: '弹幕点播-今日'
title: '弹幕点播-今日',
forceReload: true,
}
},
{
@@ -32,7 +35,8 @@ export default {
name: 'obs-queue',
component: () => import('@/views/obs/QueueOBS.vue'),
meta: {
title: '弹幕排队'
title: '弹幕排队',
forceReload: true,
}
},
{
@@ -40,7 +44,8 @@ export default {
name: 'obs-music-request',
component: () => import('@/views/obs/MusicRequestOBS.vue'),
meta: {
title: '弹幕排队 (播放'
title: '弹幕排队 (播放列表)',
forceReload: true,
}
},
{
@@ -48,7 +53,8 @@ export default {
name: 'obs-question-display',
component: () => import('@/views/obs/QuestionDisplayOBS.vue'),
meta: {
title: '棉花糖展示'
title: '棉花糖展示',
forceReload: true,
}
},
{
@@ -56,7 +62,8 @@ export default {
name: 'obs-web-fetcher',
component: () => import('@/views/obs/WebFetcherOBS.vue'),
meta: {
title: '弹幕收集器 (OBS版'
title: '弹幕收集器 (OBS版)',
forceReload: true,
}
},
{
@@ -64,7 +71,8 @@ export default {
name: 'obs-danmuji',
component: () => import('@/views/obs/DanmujiOBS.vue'),
meta: {
title: '弹幕'
title: '弹幕',
forceReload: true,
}
}
]

View File

@@ -21,7 +21,8 @@ export default [
component: () => import('@/client/ClientDanmakuWindow.vue'),
meta: {
title: '弹幕窗口',
ignoreLogin: true
ignoreLogin: true,
forceReload: true,
}
}
]

View File

@@ -243,7 +243,7 @@ onMounted(async () => {
style="width: 100%"
>
<NAlert type="success">
你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得
你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得. 把这个链接复制到浏览器打开即可登录
</NAlert>
<NText> 你的登陆链接为: </NText>
<NInputGroup>

View File

@@ -84,12 +84,14 @@ watch(aplayer, () => {
// 邮箱验证相关
const canResendEmail = ref(false)
const isBiliVerified = computed(() => accountInfo.value?.isBiliVerified)
// 图标渲染函数 - 用于菜单项
const renderIcon = (icon: any) => () => h(NIcon, null, { default: () => h(icon) })
// 菜单配置
const menuOptions = [
const menuOptions = computed(() => {
return [
{
label: () => h(RouterLink, { to: { name: 'manage-history' } }, { default: () => '历史' }),
key: 'manage-history',
@@ -233,17 +235,17 @@ const menuOptions = [
),
]
),
default: () => accountInfo.value?.isBiliVerified
default: () => isBiliVerified.value
? '需要使用直播弹幕的功能'
: '你尚未进行 Bilibili 认证, 请前往面板进行绑定',
},
),
key: 'manage-danmaku',
icon: renderIcon(Chat24Filled),
disabled: accountInfo.value?.isEmailVerified === false,
disabled: accountInfo.value?.isEmailVerified === false || !isBiliVerified.value,
children: [
{
label: () => h(
label: () => !isBiliVerified.value ? '弹幕机' : h(
NBadge,
{ value: '新', offset: [15, 12], type: 'info' },
() => h(
@@ -260,19 +262,21 @@ const menuOptions = [
)
),
key: 'manage-danmuji',
disabled: !isBiliVerified.value,
icon: renderIcon(Lottery24Filled),
},
{
label: () => h(
label: () => !isBiliVerified.value ? '抽奖' : h(
RouterLink,
{ to: { name: 'manage-liveLottery' } },
{ default: () => '抽奖' },
),
key: 'manage-liveLottery',
icon: renderIcon(Lottery24Filled),
disabled: !isBiliVerified.value,
},
{
label: () => h(
label: () => !isBiliVerified.value ? '点播' : h(
NTooltip,
{},
{
@@ -286,9 +290,10 @@ const menuOptions = [
),
key: 'manage-liveRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
},
{
label: () => h(
label: () => !isBiliVerified.value ? '点歌' : h(
NTooltip,
{},
{
@@ -302,28 +307,32 @@ const menuOptions = [
),
key: 'manage-musicRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
},
{
label: () => h(
label: () => !isBiliVerified.value ? '排队' : h(
RouterLink,
{ to: { name: 'manage-liveQueue' } },
{ default: () => '排队' },
),
key: 'manage-liveQueue',
icon: renderIcon(PeopleQueue24Filled),
disabled: !isBiliVerified.value,
},
{
label: () => h(
label: () => !isBiliVerified.value ? '读弹幕' : h(
RouterLink,
{ to: { name: 'manage-speech' } },
{ default: () => '读弹幕' },
),
key: 'manage-speech',
icon: renderIcon(TabletSpeaker24Filled),
disabled: !isBiliVerified.value,
},
],
},
]
]
})
// 重发验证邮件
async function resendEmail() {
@@ -385,7 +394,7 @@ onMounted(() => {
<!-- 顶部导航栏 -->
<NLayoutHeader
bordered
style="height: 50px; padding: 10px 15px 5px 15px"
style="height: var(--vtsuru-header-height); padding: 10px 15px 5px 15px"
>
<NPageHeader>
<template #title>
@@ -432,6 +441,7 @@ onMounted(() => {
>
<!-- 侧边导航栏 -->
<NLayoutSider
v-if="accountInfo?.isEmailVerified"
ref="sider"
bordered
show-trigger
@@ -541,8 +551,8 @@ onMounted(() => {
<!-- 内容区域 -->
<NLayout>
<!-- 主内容区域 -->
<NScrollbar :style="`height: calc(100vh - 50px - ${aplayerHeight}px)`">
<NLayoutContent content-style="margin: 12px; margin-right: 16px">
<NScrollbar :style="`height: calc(100vh - var(--vtsuru-header-height) - ${aplayerHeight}px)`">
<NLayoutContent content-style="margin: var(--vtsuru-content-padding); margin-right: calc(var(--vtsuru-content-padding) + 4px)">
<NElement>
<!-- 已验证邮箱的用户显示内容 -->
<RouterView
@@ -706,12 +716,13 @@ onMounted(() => {
position: fixed;
top: 0;
left: 0;
overflow: auto;
"
:class="isDarkMode ? 'login-dark-bg' : ''"
>
<template v-if="!isLoadingAccount">
<NCard
style="max-width: 520px; width: 100%; min-width: 350px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); margin: 16px;"
class="login-card"
:bordered="false"
>
<template #header>
@@ -753,7 +764,7 @@ onMounted(() => {
size="small"
>
<div style="text-align: center;">
如果你不是主播且不发送棉花糖(提问)的话则不需要注册登录
如果你不是主播且不发送棉花糖(提问)的话则不需要注册登录, 直接访问认证完成后给出的链接即可
</div>
<NFlex
justify="center"
@@ -762,7 +773,7 @@ onMounted(() => {
<NButton
type="primary"
size="small"
@click="$router.push({ name: 'bili-user'})"
@click="$router.push({ name: 'bili-user' })"
>
<template #icon>
<NIcon :component="BrowsersOutline" />
@@ -792,8 +803,8 @@ onMounted(() => {
</template>
<template v-else>
<NCard
class="loading-card"
:bordered="false"
style="min-width: 300px; width: 100%; max-width: 400px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); margin: 16px;"
>
<NFlex
vertical
@@ -816,6 +827,31 @@ onMounted(() => {
<style scoped>
.login-dark-bg {
background: linear-gradient(135deg, rgba(30,30,35,0.9) 0%, rgba(20,20,25,0.95) 100%) !important;
background: linear-gradient(135deg, rgba(30, 30, 35, 0.9) 0%, rgba(20, 20, 25, 0.95) 100%) !important;
}
.login-card {
max-width: 520px;
width: 90%;
min-width: 300px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
margin: 16px;
}
.loading-card {
min-width: 280px;
width: 90%;
max-width: 400px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
margin: 16px;
}
@media (max-width: 480px) {
.login-card, .loading-card {
width: 95%;
margin: 8px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,20 @@
<script setup lang="ts">
import { useDanmakuClient } from '@/store/useDanmakuClient';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import MessageRender from './blivechat/MessageRender.vue';
// @ts-ignore
import * as constants from './blivechat/constants';
// @ts-ignore
import * as pronunciation from './blivechat/utils/pronunciation';
import { DownloadConfig, useAccount } from '@/api/account';
import { useAccount, DownloadConfig, GetConfigHash } from '@/api/account';
import { QueryGetAPI } from '@/api/query';
import { VTSURU_API_URL } from '@/data/constants';
import { DanmakuInfo, GiftInfo, GuardInfo, SCInfo } from '@/data/DanmakuClients/OpenLiveClient';
import { defaultDanmujiCss, VTSURU_API_URL } from '@/data/constants';
import { useWebRTC } from '@/store/useRTC';
import { NAlert } from 'naive-ui';
import { useRoute } from 'vue-router';
// @ts-ignore
import * as pronunciation from './blivechat/utils/pronunciation';
import { EventDataTypes, EventModel } from '@/api/api-models';
// @ts-ignore
import * as trie from './blivechat/utils/trie';
export interface DanmujiConfig {
@@ -39,20 +40,6 @@ export interface DanmujiConfig {
}[]
}
defineExpose({ setCss })
const { customCss, isOBS = true } = defineProps<{
customCss?: string
isOBS?: boolean,
active?: boolean,
visible?: boolean,
}>()
const messageRender = ref()
const client = await useDanmakuClient().initOpenlive()
const pronunciationConverter = new pronunciation.PronunciationConverter()
const accountInfo = useAccount()
const route = useRoute()
// 默认配置
const defaultConfig: DanmujiConfig = {
minGiftPrice: 0.1,
@@ -74,9 +61,29 @@ const defaultConfig: DanmujiConfig = {
emoticons: []
}
defineExpose({ setCss, testAddMessage })
const props = defineProps<{
active?: boolean,
visible?: boolean,
config?: DanmujiConfig
}>()
const customCss = useStorage('danmuji-css', '');
const isOBS = computed(() => {
// @ts-ignore
return window.obsstudio !== undefined
})
const messageRender = ref()
const client = await useDanmakuClient().initOpenlive()
const pronunciationConverter = new pronunciation.PronunciationConverter()
const accountInfo = useAccount()
const route = useRoute()
const config = computed(() => props.config ?? defaultConfig)
let textEmoticons: { keyword: string, url: string }[] = []
const config = ref<DanmujiConfig>(JSON.parse(JSON.stringify(defaultConfig)))
const rtc = await useWebRTC().Init('slave')
// 表情词典树计算
const emoticonsTrie = computed(() => {
@@ -113,27 +120,27 @@ function setCss(css: string) {
/**
* 处理弹幕消息
*/
async function onAddText(data: DanmakuInfo, command: unknown) {
if (!config.value.showDanmaku || !filterTextMessage(data)) {
async function onAddText(event: EventModel, command: unknown) {
if (!config.value.showDanmaku || !filterTextMessage(event)) {
return
}
const richContent = await getRichContent(data)
const richContent = await getRichContent(event)
// 合并要放在异步调用后面,因为异步调用后可能有新的消息,会漏合并
if (mergeSimilarText(data.msg)) {
if (mergeSimilarText(event.msg)) {
return
}
const message = {
id: data.msg_id,
id: `msg-${new Date().getTime()}-${event.uid}`,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: data.uface,
time: new Date(data.timestamp * 1000),
authorName: data.uname,
authorType: getAuthorType(data.open_id, data.guard_level),
content: data.msg,
avatarUrl: event.uface,
time: new Date(),
authorName: event.uname,
authorType: getAuthorType(event.open_id, event.guard_level),
content: event.msg,
richContent: richContent,
privilegeType: data.guard_level,
privilegeType: event.guard_level,
repeated: 1,
translation: ''
}
@@ -143,32 +150,32 @@ async function onAddText(data: DanmakuInfo, command: unknown) {
/**
* 处理礼物消息
*/
function onAddGift(data: GiftInfo, command: unknown) {
function onAddGift(event: EventModel, command: unknown) {
if (!config.value.showGift) {
return
}
const price = (data.price * data.gift_num) / 1000
const price = (event.price * event.num) / 1000
// 价格过滤
if (price < (config.value.minGiftPrice ?? 0)) {
return
}
// 尝试合并相似礼物
if (mergeSimilarGift(data.uname, price, !data.paid ? price : 0, data.gift_name, data.gift_num)) {
if (mergeSimilarGift(event.uname, price, !event.price ? price : 0, event.msg, event.num)) {
return
}
const message = {
id: data.msg_id,
id: `gift-${new Date().getTime()}-${event.uid}`,
type: constants.MESSAGE_TYPE_GIFT,
avatarUrl: data.uface,
time: new Date(data.timestamp * 1000),
authorName: data.uname,
authorNamePronunciation: getPronunciation(data.uname),
avatarUrl: event.uface,
time: new Date(),
authorName: event.uname,
authorNamePronunciation: getPronunciation(event.uname),
price: price,
giftName: data.gift_name,
num: data.gift_num
giftName: event.msg,
num: event.num
}
messageRender.value.addMessage(message)
}
@@ -176,19 +183,19 @@ function onAddGift(data: GiftInfo, command: unknown) {
/**
* 处理舰长上舰消息
*/
function onAddMember(data: GuardInfo, command: unknown) {
if (!config.value.showGift || !filterNewMemberMessage(data)) {
function onAddMember(event: EventModel, command: unknown) {
if (!config.value.showGift || !filterNewMemberMessage(event)) {
return
}
const message = {
id: data.msg_id,
id: `${event.type}-${new Date().getTime()}-${event.uid}`,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: data.user_info.uface,
time: new Date(data.timestamp * 1000),
authorName: data.user_info.uname,
authorNamePronunciation: getPronunciation(data.user_info.uname),
privilegeType: data.guard_level,
avatarUrl: event.uface,
time: new Date(),
authorName: event.uname,
authorNamePronunciation: getPronunciation(event.uname),
privilegeType: event.guard_level,
title: '新舰长'
}
messageRender.value.addMessage(message)
@@ -197,24 +204,24 @@ function onAddMember(data: GuardInfo, command: unknown) {
/**
* 处理醒目留言消息
*/
function onAddSuperChat(data: SCInfo) {
if (!config.value.showGift || !filterSuperChatMessage(data)) {
function onAddSuperChat(event: EventModel, command: unknown) {
if (!config.value.showGift || !filterSuperChatMessage(event)) {
return
}
if (data.rmb < (config.value.minGiftPrice ?? 0)) {
if (event.price < (config.value.minGiftPrice ?? 0)) {
return
}
const message = {
id: data.msg_id,
id: `${event.type}-${new Date().getTime()}-${event.uid}`,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.uface,
authorName: data.uname,
authorNamePronunciation: getPronunciation(data.uname),
price: data.rmb,
time: new Date(data.timestamp * 1000),
content: data.msg_id.trim(),
avatarUrl: event.uface,
authorName: event.uname,
authorNamePronunciation: getPronunciation(event.uname),
price: event.price,
time: new Date(),
content: event.msg.trim(),
translation: ''
}
messageRender.value.addMessage(message)
@@ -223,8 +230,31 @@ function onAddSuperChat(data: SCInfo) {
/**
* 处理SC撤回
*/
function onDelSuperChat(data: { id: string }) {
messageRender.value.deleteMessage(data.id)
function onDelSuperChat(event: EventModel, command: unknown) {
let messageIdsToDelete: string[] = [];
// 尝试从command中获取需要删除的SC ID
if (command && typeof command === 'object' && 'data' in command) {
const commandData = command.data;
if (commandData && typeof commandData === 'object') {
if ('message_ids' in commandData && Array.isArray(commandData.message_ids)) {
messageIdsToDelete = commandData.message_ids.map(id => String(id));
} else if ('message_id' in commandData) {
messageIdsToDelete.push(String(commandData.message_id));
}
}
}
// 尝试使用消息内容作为ID
else if (event.msg) {
messageIdsToDelete.push(event.msg);
}
if (messageIdsToDelete.length > 0) {
console.log(`正在删除SCID: ${messageIdsToDelete.join(', ')}`);
messageIdsToDelete.forEach(id => messageRender.value.deleteMessage(id));
} else {
console.warn("收到删除SC事件但无法确定要删除的消息ID", event, command);
}
}
/**
@@ -251,15 +281,15 @@ type RichContentType = {
/**
* 获取富文本内容(处理表情等)
*/
async function getRichContent(data: DanmakuInfo): Promise<RichContentType[]> {
async function getRichContent(data: EventModel): Promise<RichContentType[]> {
const richContent: RichContentType[] = []
// 官方的非文本表情
if (data.emoji_img_url) {
if (data.emoji) {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: data.msg,
url: data.emoji_img_url + '@256w_256h_1e_1c',
url: data.emoji + '@256w_256h_1e_1c',
width: 256,
height: 256
})
@@ -379,15 +409,15 @@ function getPronunciation(text: string): string {
/**
* 过滤SC消息
*/
function filterSuperChatMessage(data: SCInfo): boolean {
return filterByContent(data.message) && filterByAuthorName(data.uname)
function filterSuperChatMessage(data: EventModel): boolean {
return filterByContent(data.msg) && filterByAuthorName(data.uname)
}
/**
* 过滤新舰长消息
*/
function filterNewMemberMessage(data: GuardInfo): boolean {
return filterByAuthorName(data.user_info.uname)
function filterNewMemberMessage(data: EventModel): boolean {
return filterByAuthorName(data.uname)
}
/**
@@ -407,13 +437,13 @@ function filterByContent(content: string): boolean {
* 根据用户名过滤消息
*/
function filterByAuthorName(id: string): boolean {
return !(id in accountInfo.value.biliBlackList)
return !(accountInfo.value && accountInfo.value.biliBlackList && id in accountInfo.value.biliBlackList)
}
/**
* 过滤弹幕消息
*/
function filterTextMessage(data: DanmakuInfo): boolean {
function filterTextMessage(data: EventModel): boolean {
// 舰长等级过滤
if (config.value.blockLevel > 0 && data.guard_level < config.value.blockLevel) {
return false
@@ -445,26 +475,139 @@ function mergeSimilarGift(authorName: string, price: number, freePrice: number,
return messageRender.value.mergeSimilarGift(authorName, price, freePrice, giftName, num)
}
// --- 修改测试方法 ---
/**
* 接收配置更新
* 用于测试,手动触发消息添加
* @param rawEventData 测试用的 EventModel 部分数据和可选的 data 负载
*/
function onReceiveConfig(data: DanmujiConfig) {
config.value = data
async function testAddMessage(rawEventData: Partial<EventModel> & { type: EventDataTypes, data?: any }) {
const event: EventModel = {
type: rawEventData.type,
uname: rawEventData.uname ?? '测试用户',
uface: rawEventData.uface ?? '',
uid: rawEventData.uid ?? 1000,
open_id: rawEventData.open_id ?? 'test_open_id',
msg: rawEventData.msg ?? '',
time: rawEventData.time ?? Date.now() / 1000,
num: rawEventData.num ?? 1,
price: rawEventData.price ?? 0,
guard_level: rawEventData.guard_level ?? 0,
fans_medal_level: rawEventData.fans_medal_level ?? 0,
fans_medal_name: rawEventData.fans_medal_name ?? '',
fans_medal_wearing_status: rawEventData.fans_medal_wearing_status ?? false,
emoji: rawEventData.emoji,
ouid: rawEventData.ouid ?? '',
...(rawEventData.data ? { data: rawEventData.data } : {})
};
switch (event.type) {
case EventDataTypes.Message:
await onAddText(event, null);
break;
case EventDataTypes.Gift:
onAddGift(event, null);
break;
case EventDataTypes.Guard:
onAddMember(event, null);
break;
case EventDataTypes.SC:
onAddSuperChat(event, null);
break;
case EventDataTypes.SCDel:
onDelSuperChat(event, null);
break;
default:
console.warn('Unsupported test event type:', event.type);
}
}
// --- 结束修改测试方法 ---
/**
* 添加系统通知消息
*/
function addSystemNotice(message: string) {
if (!messageRender.value) return;
const systemMessage = {
id: `system-${Date.now()}`,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: '',
time: new Date(),
authorName: '系统通知',
authorType: 2, // 使用特殊类型标识系统消息
content: message,
richContent: [{
type: constants.CONTENT_TYPE_TEXT,
text: message
}],
privilegeType: 0,
repeated: 1,
translation: '',
isSystem: true // 添加标记以便在UI中特殊处理
}
messageRender.value.addMessage(systemMessage)
}
let configHashCheckTimer: ReturnType<typeof setInterval> | null = null;
let currentConfigHash: string | null = null;
// 从服务器获取配置
async function getConfigFromServer() {
try {
const result = await DownloadConfig<DanmujiConfig>('danmuji-config');
if (result.status === 'success' && result.data) {
Object.assign(config.value, result.data);
console.log('已从服务器获取弹幕姬配置');
addSystemNotice('配置已从服务器更新');
return true;
} else if (result.status === 'notfound') {
console.log('服务器上未找到弹幕姬配置');
} else {
console.error(`获取配置失败: ${result.msg}`);
}
} catch (error) {
console.error('获取配置文件出错:', error);
}
return false;
}
// 检查配置文件哈希值
async function checkConfigHash() {
if (!isOBS.value) return;
try {
const hash = await GetConfigHash('danmuji-config');
if (hash && hash !== currentConfigHash) {
console.log('配置文件已更新,正在获取新配置...');
currentConfigHash = hash;
await getConfigFromServer();
}
} catch (error) {
console.error('检查配置哈希值出错:', error);
}
}
// 启动定时检查配置
function startConfigHashCheck() {
if (!isOBS.value) return;
// 先获取一次当前哈希值
GetConfigHash('danmuji-config').then(hash => {
currentConfigHash = hash;
});
// 设置定时检查每5秒检查一次
configHashCheckTimer = setInterval(checkConfigHash, 5000);
}
onMounted(async () => {
// 注册事件监听
client.on('danmaku', onAddText)
client.on('gift', onAddGift)
client.on('sc', onAddSuperChat)
client.on('guard', onAddMember)
client.onEvent('danmaku', onAddText)
client.onEvent('gift', onAddGift)
client.onEvent('sc', onAddSuperChat)
client.onEvent('guard', onAddMember)
client.onEvent('scDel', onDelSuperChat)
// 注册RTC配置接收
if (rtc) {
rtc.on('danmuji.config', onReceiveConfig)
}
// 加载表情包
try {
const result = await QueryGetAPI<{ keyword: string, url: string }[]>(VTSURU_API_URL + 'blivechat/emoticon')
if (result.code === 200) {
@@ -473,18 +616,41 @@ onMounted(async () => {
} catch (error) {
console.error('加载表情包失败:', error)
}
// 监听CSS变化
watch(customCss, (newVal) => {
messageRender.value?.setCss(newVal)
})
// 显示弹幕姬加载完成的通知
setTimeout(() => {
addSystemNotice('加载完成');
}, 300);
// 在OBS环境下获取配置并启动配置检查
// @ts-ignore
if (window.obsstudio) {
await getConfigFromServer();
startConfigHashCheck();
messageRender.value?.setCss(defaultDanmujiCss)
console.log('设置默认CSS')
} else {
messageRender.value?.setCss(customCss.value)
}
})
onUnmounted(() => {
// 取消事件监听
client.off('danmaku', onAddText)
client.off('gift', onAddGift)
client.off('sc', onAddSuperChat)
client.off('guard', onAddMember)
client.offEvent('danmaku', onAddText)
client.offEvent('gift', onAddGift)
client.offEvent('sc', onAddSuperChat)
client.offEvent('guard', onAddMember)
client.offEvent('scDel', onDelSuperChat)
// 取消RTC配置接收
if (rtc) {
rtc.off('danmuji.config', onReceiveConfig)
// 清除定时器
if (configHashCheckTimer) {
clearInterval(configHashCheckTimer);
configHashCheckTimer = null;
}
})
</script>
@@ -504,3 +670,10 @@ onUnmounted(() => {
style="height: 100%; width: 100%"
/>
</template>
<style scoped>
.body {
background-color: transparent;
}
</style>

View File

@@ -22,38 +22,34 @@
</yt-live-chat-author-badge-renderer>
</template>
<script>
import { NTooltip } from 'naive-ui';
<script setup>
import { computed } from 'vue'
import { NTooltip } from 'naive-ui'
import * as constants from './constants'
import { FILE_BASE_URL } from '@/data/constants';
import { FILE_BASE_URL } from '@/data/constants'
export default {
name: 'AuthorBadge',
props: {
const props = defineProps({
isAdmin: Boolean,
privilegeType: Number
},
components: {
NTooltip
},
computed: {
authorTypeText() {
if (this.isAdmin) {
})
const authorTypeText = computed(() => {
if (props.isAdmin) {
return 'moderator'
}
return this.privilegeType > 0 ? 'member' : ''
},
readableAuthorTypeText() {
if (this.isAdmin) {
return props.privilegeType > 0 ? 'member' : ''
})
const readableAuthorTypeText = computed(() => {
if (props.isAdmin) {
return '管理员'
}
return constants.getShowGuardLevelText(this.privilegeType)
},
fileServerUrl() {
return constants.getShowGuardLevelText(props.privilegeType)
})
const fileServerUrl = computed(() => {
return FILE_BASE_URL
}
}
}
})
</script>
<style src="@/assets/css/youtube/yt-live-chat-author-badge-renderer.css"></style>

View File

@@ -1,50 +1,63 @@
<template>
<yt-live-chat-author-chip>
<span id="author-name" dir="auto" class="style-scope yt-live-chat-author-chip"
:class="{ member: isInMemberMessage }" :type="authorTypeText">
<span
id="author-name"
dir="auto"
class="style-scope yt-live-chat-author-chip"
:class="{ member: isInMemberMessage }"
:type="authorTypeText"
>
{{ authorName }}
<!-- 这里是已验证勋章 -->
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
<span
id="chip-badges"
class="style-scope yt-live-chat-author-chip"
/>
</span>
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
<author-badge v-if="isInMemberMessage" class="style-scope yt-live-chat-author-chip" :isAdmin="false"
:privilegeType="privilegeType"></author-badge>
<span
id="chat-badges"
class="style-scope yt-live-chat-author-chip"
>
<author-badge
v-if="isInMemberMessage"
class="style-scope yt-live-chat-author-chip"
:is-admin="false"
:privilege-type="privilegeType"
/>
<template v-else>
<author-badge v-if="authorType === AUTHOR_TYPE_ADMIN" class="style-scope yt-live-chat-author-chip" isAdmin
:privilegeType="0"></author-badge>
<author-badge v-if="privilegeType > 0" class="style-scope yt-live-chat-author-chip" :isAdmin="false"
:privilegeType="privilegeType"></author-badge>
<author-badge
v-if="authorType === AUTHOR_TYPE_ADMIN"
class="style-scope yt-live-chat-author-chip"
is-admin
:privilege-type="0"
/>
<author-badge
v-if="privilegeType > 0"
class="style-scope yt-live-chat-author-chip"
:is-admin="false"
:privilege-type="privilegeType"
/>
</template>
</span>
</yt-live-chat-author-chip>
</template>
<script>
import { defineComponent } from 'vue';
<script setup>
import { computed } from 'vue'
import AuthorBadge from './AuthorBadge.vue'
import * as constants from './constants'
export default defineComponent({
name: 'AuthorChip',
components: {
AuthorBadge
},
props: {
const props = defineProps({
isInMemberMessage: Boolean,
authorName: String,
authorType: Number,
privilegeType: Number
},
data() {
return {
AUTHOR_TYPE_ADMIN: constants.AUTHOR_TYPE_ADMIN
}
},
computed: {
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
}
}
})
const AUTHOR_TYPE_ADMIN = constants.AUTHOR_TYPE_ADMIN
const authorTypeText = computed(() => {
return constants.AUTHOR_TYPE_TO_TEXT[props.authorType]
})
</script>

View File

@@ -1,36 +1,43 @@
<template>
<yt-img-shadow class="no-transition" :height="height" :width="width" style="background-color: transparent;" loaded>
<img id="img" class="style-scope yt-img-shadow" alt="" :height="height" :width="width" :src="showImgUrl"
@error="onLoadError" referrerpolicy="no-referrer">
<yt-img-shadow
class="no-transition"
:height="height"
:width="width"
style="background-color: transparent;"
loaded
>
<img
id="img"
class="style-scope yt-img-shadow"
alt=""
:height="height"
:width="width"
:src="showImgUrl"
referrerpolicy="no-referrer"
@error="onLoadError"
>
</yt-img-shadow>
</template>
<script>
<script setup>
import { ref, watch } from 'vue'
import * as models from '../../../data/chat/models'
export default {
name: 'ImgShadow',
props: {
const props = defineProps({
imgUrl: String,
height: String,
width: String
},
data() {
return {
showImgUrl: this.imgUrl
}
},
watch: {
imgUrl(val) {
this.showImgUrl = val
}
},
methods: {
onLoadError() {
if (this.showImgUrl !== models.DEFAULT_AVATAR_URL) {
this.showImgUrl = models.DEFAULT_AVATAR_URL
}
}
})
const showImgUrl = ref(props.imgUrl)
watch(() => props.imgUrl, (val) => {
showImgUrl.value = val
})
function onLoadError() {
if (showImgUrl.value !== models.DEFAULT_AVATAR_URL) {
showImgUrl.value = models.DEFAULT_AVATAR_URL
}
}
</script>

View File

@@ -1,52 +1,80 @@
<template>
<yt-live-chat-membership-item-renderer class="style-scope yt-live-chat-item-list-renderer" show-only-header
<yt-live-chat-membership-item-renderer
class="style-scope yt-live-chat-item-list-renderer"
show-only-header
:blc-guard-level="privilegeType"
>
<div id="card" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header" class="style-scope yt-live-chat-membership-item-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-membership-item-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="header-content" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-inner-column" class="style-scope yt-live-chat-membership-item-renderer">
<author-chip class="style-scope yt-live-chat-membership-item-renderer"
isInMemberMessage :authorName="authorName" :authorType="0" :privilegeType="privilegeType"
></author-chip>
<div
id="card"
class="style-scope yt-live-chat-membership-item-renderer"
>
<div
id="header"
class="style-scope yt-live-chat-membership-item-renderer"
>
<img-shadow
id="author-photo"
height="40"
width="40"
class="style-scope yt-live-chat-membership-item-renderer"
:img-url="avatarUrl"
/>
<div
id="header-content"
class="style-scope yt-live-chat-membership-item-renderer"
>
<div
id="header-content-primary-column"
class="style-scope yt-live-chat-membership-item-renderer"
>
<div
id="header-content-inner-column"
class="style-scope yt-live-chat-membership-item-renderer"
>
<author-chip
class="style-scope yt-live-chat-membership-item-renderer"
is-in-member-message
:author-name="authorName"
:author-type="0"
:privilege-type="privilegeType"
/>
</div>
<div id="header-subtext" class="style-scope yt-live-chat-membership-item-renderer">{{ title }}</div>
<div
id="header-subtext"
class="style-scope yt-live-chat-membership-item-renderer"
>
{{ title }}
</div>
</div>
<div
id="timestamp"
class="style-scope yt-live-chat-membership-item-renderer"
>
{{ timeText }}
</div>
<div id="timestamp" class="style-scope yt-live-chat-membership-item-renderer">{{ timeText }}</div>
</div>
</div>
</div>
</yt-live-chat-membership-item-renderer>
</template>
<script>
<script setup>
import { computed } from 'vue'
import ImgShadow from './ImgShadow.vue'
import AuthorChip from './AuthorChip.vue'
import * as utils from './utils'
export default {
name: 'MembershipItem',
components: {
ImgShadow,
AuthorChip
},
props: {
const props = defineProps({
avatarUrl: String,
authorName: String,
privilegeType: Number,
title: String,
time: Date
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
}
}
}
})
const timeText = computed(() => {
return utils.getTimeTextHourMin(props.time)
})
</script>
<style src="@/assets/css/youtube/yt-live-chat-membership-item-renderer.css"></style>

View File

@@ -1,6 +1,9 @@
<template>
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" allow-animations
:show-only-header="!content || undefined" :style="{
<yt-live-chat-paid-message-renderer
class="style-scope yt-live-chat-item-list-renderer"
allow-animations
:show-only-header="!content || undefined"
:style="{
'--yt-live-chat-paid-message-primary-color': color.contentBg,
'--yt-live-chat-paid-message-secondary-color': color.headerBg,
'--yt-live-chat-paid-message-header-color': color.header,
@@ -10,59 +13,94 @@
}"
:blc-price-level="priceConfig.priceLevel"
>
<div id="card" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header" class="style-scope yt-live-chat-paid-message-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-paid-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{ authorName }}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{ showPriceText }}</div>
<div
id="card"
class="style-scope yt-live-chat-paid-message-renderer"
>
<div
id="header"
class="style-scope yt-live-chat-paid-message-renderer"
>
<img-shadow
id="author-photo"
height="40"
width="40"
class="style-scope yt-live-chat-paid-message-renderer"
:img-url="avatarUrl"
/>
<div
id="header-content"
class="style-scope yt-live-chat-paid-message-renderer"
>
<div
id="header-content-primary-column"
class="style-scope yt-live-chat-paid-message-renderer"
>
<div
id="author-name"
class="style-scope yt-live-chat-paid-message-renderer"
>
{{ authorName }}
</div>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{ timeText }}</span>
<div
id="purchase-amount"
class="style-scope yt-live-chat-paid-message-renderer"
>
{{ showPriceText }}
</div>
</div>
<div id="content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">{{ content }}</div>
<span
id="timestamp"
class="style-scope yt-live-chat-paid-message-renderer"
>{{ timeText }}</span>
</div>
</div>
<div
id="content"
class="style-scope yt-live-chat-paid-message-renderer"
>
<div
id="message"
dir="auto"
class="style-scope yt-live-chat-paid-message-renderer"
>
{{ content }}
</div>
</div>
</div>
</yt-live-chat-paid-message-renderer>
</template>
<script>
<script setup>
import { computed } from 'vue'
import ImgShadow from './ImgShadow.vue'
import * as constants from './constants'
import * as utils from './utils'
export default {
name: 'PaidMessage',
components: {
ImgShadow
},
props: {
const props = defineProps({
avatarUrl: String,
authorName: String,
price: Number, // 价格,人民币
priceText: String,
time: Date,
content: String
},
computed: {
priceConfig() {
return constants.getPriceConfig(this.price)
},
color() {
return this.priceConfig.colors
},
showPriceText() {
return this.priceText || `CN¥${utils.formatCurrency(this.price)}`
},
timeText() {
return utils.getTimeTextHourMin(this.time)
}
}
}
})
const priceConfig = computed(() => {
return constants.getPriceConfig(props.price)
})
const color = computed(() => {
return priceConfig.value.colors
})
const showPriceText = computed(() => {
return props.priceText || `CN¥${utils.formatCurrency(props.price)}`
})
const timeText = computed(() => {
return utils.getTimeTextHourMin(props.time)
})
</script>
<style src="@/assets/css/youtube/yt-live-chat-paid-message-renderer.css"></style>

View File

@@ -1,34 +1,68 @@
<template>
<yt-live-chat-text-message-renderer :author-type="authorTypeText" :blc-guard-level="privilegeType">
<img-shadow id="author-photo" height="24" width="24" class="style-scope yt-live-chat-text-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="content" class="style-scope yt-live-chat-text-message-renderer">
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{ timeText }}</span>
<author-chip class="style-scope yt-live-chat-text-message-renderer"
:isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType"
></author-chip>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
<yt-live-chat-text-message-renderer
:author-type="authorTypeText"
:blc-guard-level="privilegeType"
>
<img-shadow
id="author-photo"
height="24"
width="24"
class="style-scope yt-live-chat-text-message-renderer"
:img-url="avatarUrl"
/>
<div
id="content"
class="style-scope yt-live-chat-text-message-renderer"
>
<span
id="timestamp"
class="style-scope yt-live-chat-text-message-renderer"
>{{ timeText }}</span>
<author-chip
class="style-scope yt-live-chat-text-message-renderer"
:is-in-member-message="false"
:author-name="authorName"
:author-type="authorType"
:privilege-type="privilegeType"
/>
<span
id="message"
class="style-scope yt-live-chat-text-message-renderer"
>
<template v-for="(content, index) in richContent">
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
<span
v-if="content.type === CONTENT_TYPE_TEXT"
:key="index"
>{{ content.text }}</span>
<!-- 如果CSS设置的尺寸比属性设置的尺寸还大在图片加载完后布局会变化可能导致滚动卡住没什么好的解决方法 -->
<img :key="'_' + index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
<img
v-else-if="content.type === CONTENT_TYPE_IMAGE"
:id="`emoji-${content.text}`"
:key="'_' + index"
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
:width="content.width" :height="content.height"
:src="content.url"
:alt="content.text"
:shared-tooltip-text="content.text"
:width="content.width"
:height="content.height"
:class="{ 'blc-large-emoji': content.height >= 100 }"
referrerpolicy="no-referrer"
>
</template>
<NBadge :value="repeated" :max="99" v-if="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
<NBadge
v-if="repeated > 1"
:value="repeated"
:max="99"
class="style-scope yt-live-chat-text-message-renderer"
:style="{ '--repeated-mark-color': repeatedMarkColor }"
></NBadge>
/>
</span>
</div>
</yt-live-chat-text-message-renderer>
</template>
<script>
<script setup>
import { computed } from 'vue'
import ImgShadow from './ImgShadow.vue'
import AuthorChip from './AuthorChip.vue'
import * as constants from './constants'
@@ -39,14 +73,10 @@ import { NBadge } from 'naive-ui'
const REPEATED_MARK_COLOR_START = [210, 100.0, 62.5]
const REPEATED_MARK_COLOR_END = [360, 87.3, 69.2]
export default {
name: 'TextMessage',
components: {
ImgShadow,
AuthorChip,
NBadge
},
props: {
const CONTENT_TYPE_TEXT = constants.CONTENT_TYPE_TEXT
const CONTENT_TYPE_IMAGE = constants.CONTENT_TYPE_IMAGE
const props = defineProps({
avatarUrl: String,
time: Date,
authorName: String,
@@ -54,37 +84,31 @@ export default {
richContent: Array,
privilegeType: Number,
repeated: Number
},
data() {
return {
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
}
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
},
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
},
repeatedMarkColor() {
})
const timeText = computed(() => {
return utils.getTimeTextHourMin(props.time)
})
const authorTypeText = computed(() => {
return constants.AUTHOR_TYPE_TO_TEXT[props.authorType]
})
const repeatedMarkColor = computed(() => {
let color
if (this.repeated <= 2) {
if (props.repeated <= 2) {
color = REPEATED_MARK_COLOR_START
} else if (this.repeated >= 10) {
} else if (props.repeated >= 10) {
color = REPEATED_MARK_COLOR_END
} else {
color = [0, 0, 0]
let t = (this.repeated - 2) / (10 - 2)
let t = (props.repeated - 2) / (10 - 2)
for (let i = 0; i < 3; i++) {
color[i] = REPEATED_MARK_COLOR_START[i] + ((REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t)
}
}
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
}
}
}
})
</script>
<style>

View File

@@ -1,102 +1,141 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<yt-live-chat-ticker-renderer :hidden="showMessages.length === 0">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
<transition-group tag="div" :css="false" @enter="onTickerItemEnter" @leave="onTickerItemLeave" id="items"
class="style-scope yt-live-chat-ticker-renderer">
<yt-live-chat-ticker-paid-message-item-renderer v-for="message in showMessages" :key="message.raw.id"
tabindex="0" class="style-scope yt-live-chat-ticker-renderer" style="overflow: hidden;"
@click="onItemClick(message.raw)">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer" :style="{
background: message.bgColor,
}">
<div id="content" class="style-scope yt-live-chat-ticker-paid-message-item-renderer" :style="{
color: message.color
}">
<img-shadow id="author-photo" height="24" width="24"
<div
id="container"
dir="ltr"
class="style-scope yt-live-chat-ticker-renderer"
>
<transition-group
id="items"
tag="div"
:css="false"
class="style-scope yt-live-chat-ticker-renderer"
@enter="onTickerItemEnter"
@leave="onTickerItemLeave"
>
<yt-live-chat-ticker-paid-message-item-renderer
v-for="message in showMessages"
:key="message.raw.id"
tabindex="0"
class="style-scope yt-live-chat-ticker-renderer"
style="overflow: hidden;"
@click="onItemClick(message.raw)"
>
<div
id="container"
dir="ltr"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:imgUrl="message.raw.avatarUrl"></img-shadow>
<span id="text" dir="ltr"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer">{{ message.text }}</span>
:style="{
background: message.bgColor,
}"
>
<div
id="content"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:style="{
color: message.color
}"
>
<img-shadow
id="author-photo"
height="24"
width="24"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:img-url="message.raw.avatarUrl"
/>
<span
id="text"
dir="ltr"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
>{{ message.text }}</span>
</div>
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
</transition-group>
</div>
<template v-if="pinnedMessage">
<membership-item :key="pinnedMessage.id" v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-ticker-renderer" :avatarUrl="pinnedMessage.avatarUrl"
:authorName="getShowAuthorName(pinnedMessage)" :privilegeType="pinnedMessage.privilegeType"
:title="pinnedMessage.title" :time="pinnedMessage.time"></membership-item>
<paid-message :key="pinnedMessage.id" v-else class="style-scope yt-live-chat-ticker-renderer"
:price="pinnedMessage.price" :avatarUrl="pinnedMessage.avatarUrl" :authorName="getShowAuthorName(pinnedMessage)"
:time="pinnedMessage.time" :content="pinnedMessageShowContent"></paid-message>
<membership-item
v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
:key="pinnedMessage.id"
class="style-scope yt-live-chat-ticker-renderer"
:avatar-url="pinnedMessage.avatarUrl"
:author-name="getShowAuthorName(pinnedMessage)"
:privilege-type="pinnedMessage.privilegeType"
:title="pinnedMessage.title"
:time="pinnedMessage.time"
/>
<paid-message
v-else
:key="pinnedMessage.id"
class="style-scope yt-live-chat-ticker-renderer"
:price="pinnedMessage.price"
:avatar-url="pinnedMessage.avatarUrl"
:author-name="getShowAuthorName(pinnedMessage)"
:time="pinnedMessage.time"
:content="pinnedMessageShowContent"
/>
</template>
</yt-live-chat-ticker-renderer>
</template>
<script>
<script setup>
// @ts-nocheck
import { ref, computed, onBeforeUnmount, nextTick } from 'vue'
import { formatCurrency } from './utils'
import ImgShadow from './ImgShadow.vue'
import MembershipItem from './MembershipItem.vue'
import PaidMessage from './PaidMessage.vue'
import * as constants from './constants'
export default {
name: 'Ticker',
components: {
ImgShadow,
MembershipItem,
PaidMessage
},
props: {
const props = defineProps({
messages: Array,
showGiftName: {
type: Boolean,
default: false
}
},
data() {
return {
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
})
curTime: new Date(),
updateTimerId: window.setInterval(this.updateProgress, 1000),
pinnedMessage: null
}
},
computed: {
showMessages() {
const emit = defineEmits(['update:messages'])
const MESSAGE_TYPE_MEMBER = constants.MESSAGE_TYPE_MEMBER
const curTime = ref(new Date())
const pinnedMessage = ref(null)
// 定时更新进度
const updateTimerId = window.setInterval(updateProgress, 1000)
onBeforeUnmount(() => {
window.clearInterval(updateTimerId)
})
const showMessages = computed(() => {
let res = []
for (let message of this.messages) {
if (!this.needToShow(message)) {
for (let message of props.messages) {
if (!needToShow(message)) {
continue
}
res.push({
raw: message,
bgColor: this.getBgColor(message),
color: this.getColor(message),
text: this.getText(message)
bgColor: getBgColor(message),
color: getColor(message),
text: getText(message)
})
}
return res
},
pinnedMessageShowContent() {
if (!this.pinnedMessage) {
})
const pinnedMessageShowContent = computed(() => {
if (!pinnedMessage.value) {
return ''
}
if (this.pinnedMessage.type === constants.MESSAGE_TYPE_GIFT) {
return constants.getGiftShowContent(this.pinnedMessage, this.showGiftName)
if (pinnedMessage.value.type === constants.MESSAGE_TYPE_GIFT) {
return constants.getGiftShowContent(pinnedMessage.value, props.showGiftName)
} else {
return constants.getShowContent(this.pinnedMessage)
return constants.getShowContent(pinnedMessage.value)
}
}
},
beforeDestroy() {
window.clearInterval(this.updateTimerId)
},
methods: {
async onTickerItemEnter(el, done) {
})
async function onTickerItemEnter(el, done) {
let width = el.clientWidth
if (width === 0) {
// CSS指定了不显示固定栏
@@ -104,11 +143,12 @@ export default {
return
}
el.style.width = 0
await this.$nextTick()
await nextTick()
el.style.width = `${width}px`
window.setTimeout(done, 200)
},
onTickerItemLeave(el, done) {
}
function onTickerItemLeave(el, done) {
el.classList.add('sliding-down')
window.setTimeout(() => {
el.classList.add('collapsing')
@@ -120,14 +160,16 @@ export default {
done()
}, 200)
}, 200)
},
}
getShowAuthorName: constants.getShowAuthorName,
needToShow(message) {
let pinTime = this.getPinTime(message)
const getShowAuthorName = constants.getShowAuthorName
function needToShow(message) {
let pinTime = getPinTime(message)
return (new Date() - message.addTime) / (60 * 1000) < pinTime
},
getBgColor(message) {
}
function getBgColor(message) {
let color1, color2
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
color1 = 'rgba(15,157,88,1)'
@@ -137,62 +179,65 @@ export default {
color1 = config.colors.contentBg
color2 = config.colors.headerBg
}
let pinTime = this.getPinTime(message)
let progress = (1 - ((this.curTime - message.addTime) / (60 * 1000) / pinTime)) * 100
let pinTime = getPinTime(message)
let progress = (1 - ((curTime.value - message.addTime) / (60 * 1000) / pinTime)) * 100
if (progress < 0) {
progress = 0
} else if (progress > 100) {
progress = 100
}
return `linear-gradient(90deg, ${color1}, ${color1} ${progress}%, ${color2} ${progress}%, ${color2})`
},
getColor(message) {
}
function getColor(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 'rgb(255,255,255)'
}
return constants.getPriceConfig(message.price).colors.header
},
getText(message) {
}
function getText(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return this.$t('chat.tickerMembership')
return '舰长'
}
return `CN¥${formatCurrency(message.price)}`
},
getPinTime(message) {
}
function getPinTime(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 2
}
return constants.getPriceConfig(message.price).pinTime
},
updateProgress() {
}
function updateProgress() {
// 更新进度
this.curTime = new Date()
curTime.value = new Date()
// 删除过期的消息
let filteredMessages = []
let messagesChanged = false
for (let message of this.messages) {
let pinTime = this.getPinTime(message)
if ((this.curTime - message.addTime) / (60 * 1000) >= pinTime) {
for (let message of props.messages) {
let pinTime = getPinTime(message)
if ((curTime.value - message.addTime) / (60 * 1000) >= pinTime) {
messagesChanged = true
if (this.pinnedMessage === message) {
this.pinnedMessage = null
if (pinnedMessage.value === message) {
pinnedMessage.value = null
}
continue
}
filteredMessages.push(message)
}
if (messagesChanged) {
this.$emit('update:messages', filteredMessages)
emit('update:messages', filteredMessages)
}
},
onItemClick(message) {
if (this.pinnedMessage == message) {
this.pinnedMessage = null
}
function onItemClick(message) {
if (pinnedMessage.value == message) {
pinnedMessage.value = null
} else {
this.pinnedMessage = message
}
}
pinnedMessage.value = message
}
}
</script>

View File

@@ -1,4 +1,3 @@
export const AUTHOR_TYPE_NORMAL = 0
export const AUTHOR_TYPE_MEMBER = 1
export const AUTHOR_TYPE_ADMIN = 2
@@ -181,6 +180,17 @@ export function getShowRichContent(message) {
return richContent
}
export function getShowContentParts(message) {
let contentParts = [...message.contentParts || []]
if (message.translation) {
contentParts.push({
type: CONTENT_TYPE_TEXT,
text: `${message.translation}`
})
}
return contentParts
}
export function getGiftShowContent(message, showGiftName) {
if (!showGiftName) {
return ''

View File

@@ -1,3 +1,6 @@
import { format } from 'date-fns'
export function mergeConfig(config, defaultConfig) {
let res = {}
for (let i in defaultConfig) {
@@ -36,9 +39,7 @@ export function formatCurrency(price) {
}
export function getTimeTextHourMin(date) {
let hour = date.getHours()
let min = `00${date.getMinutes()}`.slice(-2)
return `${hour}:${min}`
return format(date, 'H:mm')
}
export function getUuid4Hex() {

View File

@@ -129,10 +129,44 @@ const selectedItems = computed(() => {
// --- 方法 ---
// 获取礼物兑换按钮的提示文本
function getTooltip(goods: ResponsePointGoodModel): '开始兑换' | '当前积分不足' | '请先进行账号认证' | '库存不足' {
function getTooltip(goods: ResponsePointGoodModel): '开始兑换' | '当前积分不足' | '请先进行账号认证' | '库存不足' | '舰长等级不足' | '兑换时间未到' | '已达兑换上限' | '需要设置地址' {
if (!biliAuth.value.id) return '请先进行账号认证' // 未认证
if ((goods?.count ?? Number.MAX_VALUE) <= 0) return '库存不足' // 库存不足
if ((currentPoint.value ?? 0) < goods.price) return '当前积分不足' // 积分不足
if ((currentPoint.value ?? 0) < goods.price && !goods.canFreeBuy) return '当前积分不足' // 积分不足且不能免费兑换
// 检查舰长等级要求
// 使用 guardInfo 判断用户在当前主播房间的舰长等级
const currentGuardLevel = biliAuth.value.guardInfo?.[props.userInfo.id] ?? 0
if (goods.allowGuardLevel > 0 && currentGuardLevel < goods.allowGuardLevel) {
return '舰长等级不足'
}
// 在当前模型中没有兑换时间限制字段,可以根据需要添加相关功能
// 如果将来添加了时间限制功能,可以取消下面注释并调整代码
/*
if (goods.startTime && new Date() < new Date(goods.startTime)) {
return '兑换时间未到'
}
if (goods.endTime && new Date() > new Date(goods.endTime)) {
return '兑换已结束'
}
*/
// 检查用户兑换上限
// 注意:当前模型中没有 userBoughtCount 属性,
// 需要后端提供已购买数量信息才能实现此功能
/*
if (goods.userBoughtCount !== undefined && goods.maxBuyCount !== undefined &&
goods.userBoughtCount >= goods.maxBuyCount && goods.maxBuyCount > 0) {
return '已达兑换上限'
}
*/
// 检查实物礼物的地址要求
if (goods.type === GoodsTypes.Physical && !goods.collectUrl &&
(!biliAuth.value.address || biliAuth.value.address.length === 0)) {
return '需要设置地址'
}
return '开始兑换' // 可以兑换
}
@@ -420,7 +454,7 @@ onMounted(async () => {
<NSelect
v-model:value="priceOrder"
:options="[
{ label: '默认排序', value: null },
{ label: '默认排序', value: 'null' },
{ label: '价格 低→高', value: 'asc' },
{ label: '价格 高→低', value: 'desc' }
]"

View File

@@ -1,4 +1,4 @@
function TestVIneComponent() {
function TestVineComponent() {
return vine`
<div>
<h1>Test Vine</h1>
@@ -6,7 +6,8 @@ function TestVIneComponent() {
<p>Vine is a new way to build web applications.</p>
<p>Enjoy building with Vine!</p>
<footer>Footer content goes here.</footer>
</div>`
</div>
`
}
export default TestVIneComponent
export default TestVineComponent

View File

@@ -12,6 +12,7 @@ import Components from 'unplugin-vue-components/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import oxlintPlugin from 'vite-plugin-oxlint';
import svgLoader from 'vite-svg-loader'
import { install as VueMonacoEditorPlugin } from '@guolao/vue-monaco-editor'
const isObjectWithDefaultFunction = (
module: unknown
@@ -21,10 +22,6 @@ const isObjectWithDefaultFunction = (
'default' in module &&
typeof module.default === 'function';
const monacoEditorPlugin = isObjectWithDefaultFunction(monacoEditorPluginModule)
? monacoEditorPluginModule.default
: monacoEditorPluginModule;
export default defineConfig({
plugins: [
vue({
@@ -58,11 +55,10 @@ export default defineConfig({
extensions: ['vue', 'md'],
// allow auto import and register components used in markdown
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
include: [/\.vue$/, /\.vue\?vue/, /\.md$/, /\.vine$/],
}),
monacoEditorPlugin({ languageWorkers: ['css'] }),
oxlintPlugin(),
VineVitePlugin()
VineVitePlugin(),
],
server: { port: 51000 },
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },