feat: 更新依赖和移除不必要的文件, 更新歌单管理列表在小屏幕上的显示效果, 修复自定义配置文件加载

- 在 package.json 中移除不再使用的依赖项,并更新部分依赖版本
- 删除多个不再使用的组件和文件,包括 CheckInTemplateHelper.vue、CommonConfigItems.vue、GlobalSettingsConfig.vue 等
- 更新 bun.lockb 文件以反映依赖变更
This commit is contained in:
2025-05-03 20:17:54 +08:00
parent fe5b420d49
commit 70ff05926c
24 changed files with 302 additions and 4181 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -10,7 +10,6 @@
"knip": "knip" "knip": "knip"
}, },
"dependencies": { "dependencies": {
"@antfu/ni": "^24.3.0",
"@guolao/vue-monaco-editor": "^1.5.5", "@guolao/vue-monaco-editor": "^1.5.5",
"@hyperdx/browser": "^0.21.2", "@hyperdx/browser": "^0.21.2",
"@hyperdx/cli": "^0.1.0", "@hyperdx/cli": "^0.1.0",
@@ -30,55 +29,44 @@
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-updater": "^2.7.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/md5": "^2.3.5", "@types/md5": "^2.3.5",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@vicons/fluent": "^0.13.0", "@vicons/fluent": "^0.13.0",
"@vitejs/plugin-basic-ssl": "^2.0.0",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vue/cli": "^5.0.8",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"@vueuse/integrations": "^13.1.0", "@vueuse/integrations": "^13.1.0",
"@vueuse/router": "^13.1.0", "@vueuse/router": "^13.1.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"bilibili-live-ws": "^6.3.1", "bilibili-live-ws": "^6.3.1",
"brotli-compress": "^1.3.3",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"easy-speech": "^2.4.0", "easy-speech": "^2.4.0",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-oxlint": "^0.16.8",
"eslint-plugin-oxlint": "^0.16.7",
"eslint-plugin-prettier": "^5.2.6",
"fast-xml-parser": "^5.2.1", "fast-xml-parser": "^5.2.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"grapheme-splitter": "^1.0.4", "grapheme-splitter": "^1.0.4",
"hammerjs": "^2.0.8",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"linqts": "^2.0.0", "linqts": "^2.0.0",
"lodash": "^4.17.21",
"md5": "^2.3.0", "md5": "^2.3.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"music-metadata-browser": "^2.5.11",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"oxlint": "^0.16.7",
"peerjs": "^1.5.4", "peerjs": "^1.5.4",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"prettier": "^3.5.3",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.6.0",
"queue-typescript": "^1.0.1",
"tui-image-editor": "^3.15.3",
"unplugin-auto-import": "^19.1.2", "unplugin-auto-import": "^19.1.2",
"unplugin-vue-components": "^28.5.0", "unplugin-vue-components": "^28.5.0",
"unplugin-vue-markdown": "^28.3.1", "unplugin-vue-markdown": "^28.3.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "6.3.3", "vite": "6.3.4",
"vite-plugin-oxlint": "^1.3.1", "vite-plugin-oxlint": "^1.3.1",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "3.5.13", "vue": "3.5.13",
"vue-echarts": "^7.0.3", "vue-echarts": "^7.0.3",
"vue-request": "^2.0.4", "vue-request": "^2.0.4",
"vue-router": "^4.5.0", "vue-router": "^4.5.1",
"vue-turnstile": "^1.0.11", "vue-turnstile": "^1.0.11",
"vue3-aplayer": "^1.7.3", "vue3-aplayer": "^1.7.3",
"vue3-marquee": "^4.2.2", "vue3-marquee": "^4.2.2",
@@ -87,24 +75,15 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@types/bun": "^1.2.11",
"@types/bun": "^1.2.10",
"@types/eslint": "^9.6.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/obs-studio": "^2.17.2",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/parser": "^8.31.0",
"@vicons/ionicons5": "^0.13.0", "@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue-jsx": "^4.1.2", "@vitejs/plugin-vue-jsx": "^4.1.2",
"@vue-vine/eslint-config": "^0.2.19", "@vue-vine/eslint-config": "^0.2.19",
"@vue/eslint-config-typescript": "^14.5.0", "eslint": "^9.26.0",
"eslint": "^9.25.1", "eslint-plugin-vue": "^10.1.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-vue": "^10.0.0",
"knip": "^5.50.5",
"naive-ui": "^2.41.0",
"stylus": "^0.64.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vue-vine": "^0.3.21" "vue-vine": "^0.4.4"
} }
} }

View File

@@ -1,49 +0,0 @@
// 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 }

View File

@@ -202,7 +202,7 @@ export async function DownloadConfig<T>(name: string, id?: number): Promise<
} }
> { > {
try { try {
const resp = await QueryGetAPI<string>(USER_CONFIG_API_URL + (id ? 'user-get' : 'get'), { const resp = await QueryGetAPI<string>(USER_CONFIG_API_URL + (id ? 'get-user' : 'get'), {
name: name, name: name,
id: id id: id
}); });

View File

@@ -1,155 +0,0 @@
<template>
<div class="checkin-template-helper">
<TemplateHelper :placeholders="checkInPlaceholders" />
<NAlert
type="info"
:show-icon="false"
style="margin-top: 8px;"
>
<template #header>
<div class="alert-header">
<NIcon
:component="Info24Filled"
style="margin-right: 4px;"
/>
签到模板可用变量列表
</div>
</template>
<NDivider style="margin: 6px 0;" />
<div class="placeholder-groups">
<div class="placeholder-group">
<div class="group-title">
用户信息
</div> <div class="placeholder-item">
<code>&#123;&#123;user.name&#125;&#125;</code> - 用户名称
</div>
<div class="placeholder-item">
<code>&#123;&#123;user.uid&#125;&#125;</code> - 用户ID
</div>
</div>
<div class="placeholder-group">
<div class="group-title">
签到信息
</div> <div class="placeholder-item">
<code>&#123;&#123;checkin.points&#125;&#125;</code> - 基础签到积分
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.bonusPoints&#125;&#125;</code> - 早鸟额外积分 (普通签到为0)
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.totalPoints&#125;&#125;</code> - 总获得积分
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.isEarlyBird&#125;&#125;</code> - 是否是早鸟签到 (true/false)
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.cooldownSeconds&#125;&#125;</code> - 签到冷却时间()
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.time&#125;&#125;</code> - 签到时间对象
</div>
</div>
</div>
<NDivider style="margin: 6px 0;" />
<div class="placeholder-example">
<div class="example-title">
示例模板:
</div> <div class="example-item">
普通签到: <code>&#123;&#123;user.name&#125;&#125; 签到成功获得 &#123;&#123;checkin.totalPoints&#125;&#125; 积分</code>
</div>
<div class="example-item">
早鸟签到: <code>恭喜 &#123;&#123;user.name&#125;&#125; 完成早鸟签到额外获得 &#123;&#123;checkin.bonusPoints&#125;&#125; 积分共获得 &#123;&#123;checkin.totalPoints&#125;&#125; 积分</code>
</div>
<div class="example-item">
条件表达式: <code>&#123;&#123;js: checkin.isEarlyBird ? `恭喜 ${user.name} 获得早鸟奖励!` : `${user.name} 签到成功!`&#125;&#125; 获得 &#123;&#123;checkin.totalPoints&#125;&#125; 积分</code>
</div>
</div>
</NAlert>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { NAlert, NDivider, NIcon } from 'naive-ui';
import { Info24Filled } from '@vicons/fluent';
import TemplateHelper from './TemplateHelper.vue';
// 签到模板的特定占位符
const checkInPlaceholders = [
{ name: '{{user.name}}', description: '用户名称' },
{ name: '{{user.uid}}', description: '用户ID' },
{ name: '{{checkin.points}}', description: '基础签到积分' },
{ name: '{{checkin.bonusPoints}}', description: '早鸟额外积分 (普通签到为0)' },
{ name: '{{checkin.totalPoints}}', description: '总获得积分' },
{ name: '{{checkin.isEarlyBird}}', description: '是否是早鸟签到 (true/false)' },
{ name: '{{checkin.cooldownSeconds}}', description: '签到冷却时间(秒)' },
{ name: '{{checkin.time}}', description: '签到时间对象' }
];
</script>
<style scoped>
.checkin-template-helper {
margin-bottom: 12px;
}
.alert-header {
display: flex;
align-items: center;
font-weight: bold;
}
.placeholder-groups {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.placeholder-group {
flex: 1;
min-width: 200px;
}
.group-title {
font-weight: bold;
margin-bottom: 6px;
font-size: 14px;
}
.placeholder-item {
margin-bottom: 4px;
font-size: 13px;
}
.placeholder-item code {
padding: 1px 4px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 3px;
font-size: 12px;
}
.placeholder-example {
margin-top: 8px;
}
.example-title {
font-weight: bold;
margin-bottom: 6px;
font-size: 14px;
}
.example-item {
margin-bottom: 4px;
font-size: 13px;
}
.example-item code {
display: block;
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 3px;
margin-top: 2px;
font-size: 12px;
white-space: nowrap;
overflow: auto;
}
</style>

View File

@@ -1,120 +0,0 @@
<script setup lang="ts">
import { NSpace, NSwitch, NInputNumber, NSelect, NCheckbox, NDivider } from 'naive-ui';
defineProps({
config: {
type: Object,
required: true
},
showLiveOnly: {
type: Boolean,
default: true
},
showDelay: {
type: Boolean,
default: false
},
showUserFilter: {
type: Boolean,
default: false
},
showTianXuan: {
type: Boolean,
default: false
}
});
</script>
<template>
<div class="common-config-section">
<NSpace
vertical
size="medium"
>
<NSpace
align="center"
justify="space-between"
style="width: 100%"
>
<span>启用功能:</span>
<NSwitch v-model:value="config.enabled" />
</NSpace>
<NSpace
v-if="showLiveOnly"
align="center"
justify="space-between"
style="width: 100%"
>
<span>仅直播中开启:</span>
<NSwitch v-model:value="config.onlyDuringLive" />
</NSpace>
<NSpace
v-if="showDelay"
align="center"
justify="space-between"
style="width: 100%"
>
<span>延迟时间 ():</span>
<NInputNumber
v-model:value="config.delaySeconds"
:min="0"
:max="300"
style="width: 120px"
/>
</NSpace>
<NSpace
v-if="showTianXuan"
align="center"
justify="space-between"
style="width: 100%"
>
<span>屏蔽天选时刻:</span>
<NSwitch v-model:value="config.ignoreTianXuan" />
</NSpace>
<template v-if="showUserFilter">
<NDivider title-placement="left">
用户过滤设置
</NDivider>
<NSpace
align="center"
justify="space-between"
style="width: 100%"
>
<span>启用用户过滤:</span>
<NSwitch v-model:value="config.userFilterEnabled" />
</NSpace>
<NSpace
v-if="config.userFilterEnabled"
align="center"
justify="space-between"
style="width: 100%"
>
<span>要求本房间勋章:</span>
<NSwitch v-model:value="config.requireMedal" />
</NSpace>
<NSpace
v-if="config.userFilterEnabled"
align="center"
justify="space-between"
style="width: 100%"
>
<span>要求任意舰长:</span>
<NSwitch v-model:value="config.requireCaptain" />
</NSpace>
</template>
</NSpace>
</div>
</template>
<style scoped>
.common-config-section {
padding: 16px 0;
}
</style>

View File

@@ -1,115 +0,0 @@
<template>
<div class="template-tester">
<NSpace vertical>
<NInput
v-model:value="template"
type="textarea"
placeholder="输入包含表达式的模板"
/>
<NSpace>
<NButton
type="primary"
size="small"
@click="testTemplate"
>
测试模板
</NButton>
<NButton
size="small"
@click="resetTemplate"
>
重置
</NButton>
</NSpace>
<template
v-if="hasResult"
>
<NDivider style="margin: 5px;" />
<NCard
title="结果预览"
size="small"
>
<NInput
type="textarea"
:value="result"
readonly
/>
</NCard>
</template>
</NSpace>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { NSpace, NInput, NInputGroup, NInputGroupLabel, NButton, useMessage, NDivider } from 'naive-ui';
import { evaluateTemplateExpressions } from '@/client/store/autoAction/expressionEvaluator';
import { EventModel } from '@/api/api-models';
import { TriggerType } from '@/client/store/autoAction/types';
import { buildExecutionContext } from '@/client/store/autoAction/utils';
const props = defineProps({
defaultTemplate: {
type: String,
default: ''
},
context: {
type: Object,
required: true
}
});
const template = ref(props.defaultTemplate || '');
const result = ref('');
const hasResult = computed(() => result.value !== '');
const message = useMessage();
function evaluateTemplateForUI(template: string, contextObj: Record<string, any>): string {
const tempContext = buildExecutionContext(contextObj, undefined, TriggerType.DANMAKU);
return evaluateTemplateExpressions(template, tempContext);
}
function testTemplate() {
try {
result.value = evaluateTemplateForUI(template.value, props.context);
} catch (error) {
message.error(`表达式求值错误: ${(error as Error).message}`);
result.value = `[错误] ${(error as Error).message}`;
}
}
function resetTemplate() {
template.value = props.defaultTemplate;
result.value = '';
}
</script>
<style scoped>
.template-tester {
margin-top: 16px;
margin-bottom: 16px;
}
.result-container {
margin-top: 8px;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background-color: #f5f5f5;
}
.result-title {
font-weight: bold;
margin-bottom: 4px;
}
.result-content {
padding: 8px;
background-color: white;
border-radius: 4px;
border: 1px dashed #d9d9d9;
word-break: break-all;
}
</style>

View File

@@ -1,193 +0,0 @@
<script setup lang="ts">
import { EventDataTypes, EventModel } from '@/api/api-models';
import { DanmakuWindowSettings, useDanmakuWindow } from '../../store/useDanmakuWindow';
import { computed } from 'vue';
import { AVATAR_URL } from '@/data/constants';
import { GetGuardColor } from '@/Utils';
export interface BaseDanmakuItemProps {
item: EventModel & { randomId: string; isNew?: boolean; disappearAt?: number; };
setting: DanmakuWindowSettings;
}
const props = defineProps<BaseDanmakuItemProps>();
const emojiData = useDanmakuWindow().emojiData;
// 检查弹幕是否将要消失
const isDisappearing = computed(() => {
return props.item.disappearAt && Date.now() > props.item.disappearAt - 300; // 提前300ms进入消失动画
});
// 计算SC弹幕的颜色类
const scColorClass = computed(() => {
if (props.item.type === EventDataTypes.SC) {
const price = props.item?.price || 0;
if (price === 0) return 'sc-0';
if (price > 0 && price < 50) return 'sc-50';
if (price >= 50 && price < 100) return 'sc-100';
if (price >= 100 && price < 500) return 'sc-500';
if (price >= 500 && price < 1000) return 'sc-1000';
if (price >= 1000 && price < 2000) return 'sc-2000';
if (price >= 2000) return 'sc-max';
}
return '';
});
// 根据类型计算样式
const typeClass = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message: return 'message-item';
case EventDataTypes.Gift: return 'gift-item';
case EventDataTypes.SC: return `sc-item ${scColorClass.value}`;
case EventDataTypes.Guard: return 'guard-item';
case EventDataTypes.Enter: return 'enter-item';
default: return '';
}
});
// 获取舰长颜色
const guardColor = computed(() => GetGuardColor(props.item.guard_level));
// 舰长样式类
const guardLevelClass = computed(() => {
if (props.item.type === EventDataTypes.Guard) {
return `guard-level-${props.item.guard_level || 0}`;
}
return '';
});
// 检查是否需要显示头像
const showAvatar = computed(() => props.setting.showAvatar);
// 解析包含内联表情的消息
const parsedMessage = computed<{ type: 'text' | 'emoji'; content?: string; url?: string; name?: string; }[]>(() => {
// 仅处理非纯表情的普通消息
if (props.item.type !== EventDataTypes.Message || props.item.emoji || !props.item.msg) {
return [];
}
const segments: { type: 'text' | 'emoji'; content?: string; url?: string; name?: string; }[] = [];
let lastIndex = 0;
const regex = /\[([^\]]+)\]/g; // 匹配 [表情名]
let match;
try {
const availableEmojis = emojiData.data || {}; // 确保 emojiData 已加载
while ((match = regex.exec(props.item.msg)) !== null) {
// 添加表情前的文本部分
if (match.index > lastIndex) {
segments.push({ type: 'text', content: props.item.msg.substring(lastIndex, match.index) });
}
const emojiFullName = match[0]; // 完整匹配,例如 "[哈哈]"
const emojiInfo = availableEmojis.inline[emojiFullName] || availableEmojis.plain[emojiFullName];
if (emojiInfo) {
// 找到了表情
segments.push({ type: 'emoji', url: emojiInfo, name: emojiFullName });
} else {
// 未找到表情,当作普通文本处理
segments.push({ type: 'text', content: emojiFullName });
}
lastIndex = regex.lastIndex;
}
// 添加最后一个表情后的文本部分
if (lastIndex < props.item.msg.length) {
segments.push({ type: 'text', content: props.item.msg.substring(lastIndex) });
}
} catch (error) {
console.error("Error parsing message for emojis:", error);
// 解析出错时,返回原始文本
return [{ type: 'text', content: props.item.msg }];
}
// 如果解析后为空(例如,消息只包含无法识别的[]),则返回原始文本
if (segments.length === 0 && props.item.msg) {
return [{ type: 'text', content: props.item.msg }];
}
return segments;
});
// 获取不同类型消息的显示标签
const typeLabel = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message: return ''; // 普通消息不需要标签
case EventDataTypes.Gift: return '【礼物】';
case EventDataTypes.SC: return '【SC】';
case EventDataTypes.Guard: return '【舰长】';
case EventDataTypes.Enter: return '【进场】';
default: return '';
}
});
// 获取礼物或SC的价格文本
const priceText = computed(() => {
if (props.item.type === EventDataTypes.SC ||
(props.item.type === EventDataTypes.Gift && props.item.price > 0)) {
return `${props.item.price || 0}`;
}
return '';
});
// 获取用户名显示
const displayName = computed(() => {
return props.item.uname || '匿名用户';
});
// 获取消息显示内容
const displayContent = computed(() => {
switch (props.item.type) {
case EventDataTypes.Message:
return props.item.msg || '';
case EventDataTypes.Gift:
return `${props.item.num || 1} × ${props.item.msg}`;
case EventDataTypes.SC:
return props.item.msg || '';
case EventDataTypes.Guard:
return props.item.msg || '开通了舰长';
case EventDataTypes.Enter:
return '进入了直播间';
default:
return '';
}
});
// 根据风格及类型获取文本颜色
const textModeColor = computed(() => {
if (props.item.type === EventDataTypes.SC) {
return '#FFD700'; // SC消息金色
} else if (props.item.type === EventDataTypes.Gift) {
return '#FF69B4'; // 礼物消息粉色
} else if (props.item.type === EventDataTypes.Guard) {
return guardColor.value; // 舰长消息使用舰长颜色
} else if (props.item.type === EventDataTypes.Enter) {
return '#67C23A'; // 入场消息绿色
}
return undefined; // 普通消息使用默认颜色
});
// 向外导出所有计算属性
defineExpose({
isDisappearing,
scColorClass,
typeClass,
guardColor,
guardLevelClass,
showAvatar,
parsedMessage,
typeLabel,
priceText,
displayName,
displayContent,
textModeColor
});
</script>
<template>
<slot />
</template>

View File

@@ -43,6 +43,7 @@ import {
NTag, NTag,
NText, NText,
NTooltip, NTooltip,
NSwitch,
useMessage, // Naive UI 组件 useMessage, // Naive UI 组件
} from 'naive-ui'; } from 'naive-ui';
import { VNodeChild, computed, h, onMounted, ref, watch } from 'vue'; // Vue 核心 API import { VNodeChild, computed, h, onMounted, ref, watch } from 'vue'; // Vue 核心 API
@@ -58,6 +59,8 @@ const props = defineProps<{
// --- 响应式状态 --- // --- 响应式状态 ---
const message = useMessage() // Naive UI 消息提示 const message = useMessage() // Naive UI 消息提示
const volume = useLocalStorage('Settings.AplayerVolume', 0.8) // 播放器音量,持久化存储 const volume = useLocalStorage('Settings.AplayerVolume', 0.8) // 播放器音量,持久化存储
const showListenButton = useLocalStorage('SongList.ShowListenButton', true) // 是否显示试听按钮
const showLinkButton = useLocalStorage('SongList.ShowLinkButton', true) // 是否显示跳转按钮
const songsInternal = ref<SongsInfo[]>([]) // 内部维护的歌曲列表,避免直接修改 props const songsInternal = ref<SongsInfo[]>([]) // 内部维护的歌曲列表,避免直接修改 props
const playingSong = ref<SongsInfo>() // 当前正在试听的歌曲 const playingSong = ref<SongsInfo>() // 当前正在试听的歌曲
const isLrcLoading = ref<string>() // 歌词加载状态(存储歌曲 key const isLrcLoading = ref<string>() // 歌词加载状态(存储歌曲 key
@@ -116,6 +119,32 @@ defineExpose({
// --- 计算属性 --- // --- 计算属性 ---
// 计算操作列的预定义宽度
const actionColumnWidth = computed(() => {
const baseSelfWidth = 85; // 基础宽度 (isSelf=true, 编辑+删除)
const basePublicWidth = 40; // 基础宽度 (isSelf=false)
const listenButtonWidth = 40;
const linkButtonWidth = 40;
const extraButtonWidth = 40; // 假设的额外按钮宽度
let width = props.isSelf ? baseSelfWidth : basePublicWidth;
if (showListenButton.value) {
width += listenButtonWidth;
}
if (showLinkButton.value) {
width += linkButtonWidth;
}
if (props.extraButton) {
width += extraButtonWidth;
}
// 返回一个合理的宽度值,例如,可以设定几个档位
// 这里用之前的计算逻辑,但可以替换为固定档位如 80, 120, 160, 200, 240
// 为了精确,我们还是用计算值,但它是响应式的
return width;
});
// 筛选后的歌曲列表 // 筛选后的歌曲列表
const songsComputed = computed(() => { const songsComputed = computed(() => {
let filteredSongs = songsInternal.value; let filteredSongs = songsInternal.value;
@@ -340,18 +369,19 @@ function createColumns(): DataTableColumns<SongsInfo> {
{ {
title: '操作', title: '操作',
key: 'manage', key: 'manage',
width: props.isSelf ? 170 : 120, // 根据是否自己的歌单调整宽度
fixed: 'right', // 固定操作列在右侧 fixed: 'right', // 固定操作列在右侧
render(data) { render(data) {
const buttons: VNodeChild[] = []; const buttons: VNodeChild[] = [];
// 1. 获取播放/信息按钮 (来自 Utils) // 1. 获取播放/信息按钮 (来自 Utils)
const playButton = GetPlayButton(data); if (showLinkButton.value) { // 添加条件
if (playButton) buttons.push(playButton); const playButton = GetPlayButton(data);
if (playButton) buttons.push(playButton);
}
// 2. 试听按钮 (仅对音频文件显示) // 2. 试听按钮 (仅对音频文件显示)
const isAudio = /\.(mp3|flac|ogg|wav|m4a)$/i.test(data.url ?? ''); // 正则判断音频后缀 const isAudio = /\.(mp3|flac|ogg|wav|m4a)$/i.test(data.url ?? ''); // 正则判断音频后缀
if (isAudio) { if (showListenButton.value && isAudio) { // 添加条件
buttons.push( buttons.push(
h(NTooltip, null, { h(NTooltip, null, {
trigger: () => trigger: () =>
@@ -422,6 +452,17 @@ function createColumns(): DataTableColumns<SongsInfo> {
// 使用 NSpace 渲染所有按钮 // 使用 NSpace 渲染所有按钮
return h(NSpace, { justify: 'end', size: 8, wrap: false }, () => buttons); // 增加间距,禁止换行 return h(NSpace, { justify: 'end', size: 8, wrap: false }, () => buttons); // 增加间距,禁止换行
}, },
// --- 动态计算宽度 --- START
/* width: (() => {
let calculatedWidth = 20; // 基础内边距
if (showLinkButton.value) calculatedWidth += 40; // 链接按钮宽度
if (showListenButton.value) calculatedWidth += 40; // 试听按钮宽度
if (props.isSelf) calculatedWidth += 80; // 编辑 + 删除按钮宽度
if (props.extraButton) calculatedWidth += 40; // 额外按钮预估宽度
return Math.max(calculatedWidth, props.isSelf ? 160 : 80); // 设置最小宽度防止太窄
})(), */
width: actionColumnWidth.value, // 使用计算属性
// --- 动态计算宽度 --- END
}, },
] ]
} }
@@ -452,6 +493,12 @@ watch(
{ deep: true } // 深度监听,如果 songs 数组内部对象变化也触发 { deep: true } // 深度监听,如果 songs 数组内部对象变化也触发
) )
// 监听按钮显示状态变化,重新计算列定义以更新宽度
watch([showListenButton, showLinkButton], () => {
console.log('Button visibility changed, recalculating columns.');
columns.value = createColumns();
});
// 更新单首歌曲信息 // 更新单首歌曲信息
async function updateSong() { async function updateSong() {
try { try {
@@ -711,16 +758,26 @@ onMounted(() => {
style="min-width: 180px; flex-grow: 1;" style="min-width: 180px; flex-grow: 1;"
max-tag-count="responsive" max-tag-count="responsive"
/> />
<!-- 清除作者列筛选按钮 (当顶部选择器清除时列筛选也应清除但保留按钮以防万一) --> <!-- 显示控制开关 -->
<!-- <NButton <NSpace
v-if="authorColumn.filterOptionValue" item-style="display: flex; align-items: center;"
type="warning"
size="small" size="small"
ghost
@click="onAuthorClick(authorColumn.filterOptionValue as string)"
> >
清除歌手列筛选 <NSwitch
</NButton> --> v-model:value="showListenButton"
size="small"
/>
<NText style="font-size: 12px;">
试听
</NText>
<NSwitch
v-model:value="showLinkButton"
size="small"
/>
<NText style="font-size: 12px;">
链接
</NText>
</NSpace>
</NSpace> </NSpace>
</NCard> </NCard>
@@ -770,6 +827,7 @@ onMounted(() => {
:columns="columns" :columns="columns"
:data="songsComputed" :data="songsComputed"
size="small" size="small"
:scroll-x="800"
:pagination="{ :pagination="{
itemCount: songsInternal.length, itemCount: songsInternal.length,
defaultPageSize: pageSize, defaultPageSize: pageSize,

View File

@@ -1 +0,0 @@
import EasySpeech from 'easy-speech'

View File

@@ -1,154 +0,0 @@
import ChatClientOfficialBase, * as base from './ChatClientOfficialBase'
import { processAvatarUrl } from './models'
export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
constructor(authInfo) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
this.auth = authInfo
}
stop() {
super.stop()
}
async initRoom() {
return true
}
async onBeforeWsConnect() {
return super.onBeforeWsConnect()
}
getWsUrl() {
return this.auth.websocket_info.wss_link[this.retryCount % this.auth.websocket_info.wss_link.length]
}
sendAuth() {
this.websocket.send(this.makePacket(this.auth.websocket_info.auth_body, base.OP_AUTH))
}
async dmCallback(command) {
if (!this.onAddText) {
return
}
let data = command.data
let authorType
if (data.uid === this.roomOwnerUid) {
authorType = 3
} else if (data.guard_level !== 0) {
authorType = 1
} else {
authorType = 0
}
let emoticon = null
if (data.dm_type === 1) {
emoticon = data.emoji_img_url
}
data = {
avatarUrl: processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
authorType: authorType,
content: data.msg,
privilegeType: data.guard_level,
isGiftDanmaku: false,
authorLevel: 1,
isNewbie: false,
isMobileVerified: true,
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
id: data.msg_id,
translation: '',
emoticon: emoticon,
}
this.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (!data.paid) {
// 丢人
return
}
data = {
id: data.msg_id,
avatarUrl: processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.price,
giftName: data.gift_name,
num: data.gift_num,
}
this.onAddGift(data)
}
async guardCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: data.msg_id,
avatarUrl: processAvatarUrl(data.user_info.uface),
timestamp: data.timestamp,
authorName: data.user_info.uname,
privilegeType: data.guard_level,
}
this.onAddMember(data)
}
superChatCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.message_id.toString(),
avatarUrl: processAvatarUrl(data.uface),
timestamp: data.start_time,
authorName: data.uname,
price: data.rmb,
content: data.message,
translation: '',
}
this.onAddSuperChat(data)
}
superChatDelCallback(command) {
if (!this.onDelSuperChat) {
return
}
const ids = []
for (const id of command.data.message_ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
rawMessageCallback(command) {
if (!this.onRawMessage) {
return
}
this.onRawMessage(command)
}
}
const CMD_CALLBACK_MAP = {
LIVE_OPEN_PLATFORM_DM: ChatClientDirectOpenLive.prototype.dmCallback,
LIVE_OPEN_PLATFORM_SEND_GIFT: ChatClientDirectOpenLive.prototype.sendGiftCallback,
LIVE_OPEN_PLATFORM_GUARD: ChatClientDirectOpenLive.prototype.guardCallback,
LIVE_OPEN_PLATFORM_SUPER_CHAT: ChatClientDirectOpenLive.prototype.superChatCallback,
LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL: ChatClientDirectOpenLive.prototype.superChatDelCallback,
RAW_MESSAGE: ChatClientDirectOpenLive.prototype.rawMessageCallback,
}

View File

@@ -1,176 +0,0 @@
import * as chat from './ChatClientOfficialBase'
import * as chatModels from './models.js'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
export default class ChatClientDirectWeb extends ChatClientOfficialBase {
constructor(roomId) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
// 调用initRoom后初始化如果失败使用这里的默认值
this.roomId = roomId
this.roomOwnerUid = -1
this.hostServerList = [
{
host: 'broadcastlv.chat.bilibili.com',
port: 2243,
wss_port: 443,
ws_port: 2244
}
]
this.hostServerToken = null
this.buvid = ''
}
async initRoom() {
let res
try {
res = await (
await fetch('/api/room_info?room_id=' + this.roomId, { method: 'GET' })
).json()
} catch {
return true
}
this.roomId = res.roomId
this.roomOwnerUid = res.ownerUid
if (res.hostServerList.length !== 0) {
this.hostServerList = res.hostServerList
}
this.hostServerToken = res.hostServerToken
this.buvid = res.buvid
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
let reinitPeriod = Math.max(3, (this.hostServerList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
}
return super.onBeforeWsConnect()
}
getWsUrl() {
let hostServer =
this.hostServerList[this.retryCount % this.hostServerList.length]
return `wss://${hostServer.host}:${hostServer.wss_port}/sub`
}
sendAuth() {
let authParams = {
uid: 0,
roomid: this.roomId,
protover: 3,
platform: 'web',
type: 2,
buvid: this.buvid
}
if (this.hostServerToken !== null) {
authParams.key = this.hostServerToken
}
this.websocket.send(this.makePacket(authParams, base.OP_AUTH))
}
async danmuMsgCallback(command) {
let info = command.info
let roomId, medalLevel
if (info[3]) {
roomId = info[3][3]
medalLevel = info[3][0]
} else {
roomId = medalLevel = 0
}
let uid = info[2][0]
let isAdmin = info[2][2]
let privilegeType = info[7]
let authorType
if (uid === this.roomOwnerUid) {
authorType = 3
} else if (isAdmin) {
authorType = 2
} else if (privilegeType !== 0) {
authorType = 1
} else {
authorType = 0
}
let authorName = info[2][1]
let content = info[1]
let data = new chatModels.AddTextMsg({
avatarUrl: await chat.getAvatarUrl(uid, authorName),
timestamp: info[0][4] / 1000,
authorName: authorName,
authorType: authorType,
content: content,
privilegeType: privilegeType,
isGiftDanmaku:
Boolean(info[0][9]) || chat.isGiftDanmakuByContent(content),
authorLevel: info[4][0],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
emoticon: info[0][13].url || null
})
this.msgHandler.onAddText(data)
}
sendGiftCallback(command) {
let data = command.data
let isPaidGift = data.coin_type === 'gold'
data = new chatModels.AddGiftMsg({
avatarUrl: chat.processAvatarUrl(data.face),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: isPaidGift ? data.total_coin : 0,
totalFreeCoin: !isPaidGift ? data.total_coin : 0,
giftName: data.giftName,
num: data.num
})
this.msgHandler.onAddGift(data)
}
async guardBuyCallback(command) {
let data = command.data
data = new chatModels.AddMemberMsg({
avatarUrl: await chat.getAvatarUrl(data.uid, data.username),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
})
this.msgHandler.onAddMember(data)
}
superChatMessageCallback(command) {
let data = command.data
data = new chatModels.AddSuperChatMsg({
id: data.id.toString(),
avatarUrl: chat.processAvatarUrl(data.user_info.face),
timestamp: data.start_time,
authorName: data.user_info.uname,
price: data.price,
content: data.message
})
this.msgHandler.onAddSuperChat(data)
}
superChatMessageDeleteCallback(command) {
let ids = []
for (let id of command.data.ids) {
ids.push(id.toString())
}
let data = new chatModels.DelSuperChatMsg({ ids })
this.msgHandler.onDelSuperChat(data)
}
}
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback,
SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE:
ChatClientDirectWeb.prototype.superChatMessageDeleteCallback
}

File diff suppressed because one or more lines are too long

View File

@@ -1,320 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// @ts-nocheck
import { BrotliDecode } from './brotli_decode'
import { setInterval, clearInterval, setTimeout, clearTimeout } from 'worker-timers'
const HEADER_SIZE = 16
export const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
export const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1
export const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
export const WS_BODY_PROTOCOL_VERSION_BROTLI = 3
export const OP_HANDSHAKE = 0
export const OP_HANDSHAKE_REPLY = 1
export const OP_HEARTBEAT = 2
export const OP_HEARTBEAT_REPLY = 3
export const OP_SEND_MSG = 4
export const OP_SEND_MSG_REPLY = 5
export const OP_DISCONNECT_REPLY = 6
export const OP_AUTH = 7
export const OP_AUTH_REPLY = 8
export const OP_RAW = 9
export const OP_PROTO_READY = 10
export const OP_PROTO_FINISH = 11
export const OP_CHANGE_ROOM = 12
export const OP_CHANGE_ROOM_REPLY = 13
export const OP_REGISTER = 14
export const OP_REGISTER_REPLY = 15
export const OP_UNREGISTER = 16
export const OP_UNREGISTER_REPLY = 17
// B站业务自定义OP
// export const MinBusinessOp = 1000
// export const MaxBusinessOp = 10000
export const AUTH_REPLY_CODE_OK = 0
export const AUTH_REPLY_CODE_TOKEN_ERROR = -101
const HEARTBEAT_INTERVAL = 10 * 1000
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5 * 1000
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
export default class ChatClientOfficialBase {
constructor() {
this.CMD_CALLBACK_MAP = {}
this.onAddText = null
this.onAddGift = null
this.onAddMember = null
this.onAddSuperChat = null
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.needInitRoom = true
this.websocket = null
this.retryCount = 0
this.isDestroying = false
this.heartbeatTimerId = null
this.receiveTimeoutTimerId = null
}
start() {
this.wsConnect()
}
stop() {
this.isDestroying = true
if (this.websocket) {
this.websocket.close()
}
}
async initRoom() {
throw Error('Not implemented')
}
makePacket(data, operation) {
let body
if (typeof data === 'object') {
body = textEncoder.encode(JSON.stringify(data))
} else {
// string
body = textEncoder.encode(data)
}
const header = new ArrayBuffer(HEADER_SIZE)
const headerView = new DataView(header)
headerView.setUint32(0, HEADER_SIZE + body.byteLength) // pack_len
headerView.setUint16(4, HEADER_SIZE) // raw_header_size
headerView.setUint16(6, 1) // ver
headerView.setUint32(8, operation) // operation
headerView.setUint32(12, 1) // seq_id
return new Blob([header, body])
}
sendAuth() {
throw Error('Not implemented')
}
async wsConnect() {
if (this.isDestroying) {
return
}
await this.onBeforeWsConnect()
if (this.isDestroying) {
return
}
this.websocket = new WebSocket(this.getWsUrl())
this.websocket.binaryType = 'arraybuffer'
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
}
async onBeforeWsConnect() {
if (!this.needInitRoom) {
return
}
let res
try {
res = await this.initRoom()
} catch (e) {
res = false
console.error('initRoom exception:', e)
if (this.onFatalError) {
this.onFatalError(e)
}
}
if (!res) {
this.onWsClose()
throw Error('initRoom failed')
}
this.needInitRoom = false
}
getWsUrl() {
throw Error('Not implemented')
}
onWsOpen() {
this.sendAuth()
this.heartbeatTimerId = setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
this.refreshReceiveTimeoutTimer()
//console.log('ws 已连接')
}
sendHeartbeat() {
this.websocket.send(this.makePacket({}, OP_HEARTBEAT))
}
refreshReceiveTimeoutTimer() {
if (this.receiveTimeoutTimerId) {
clearTimeout(this.receiveTimeoutTimerId)
}
this.receiveTimeoutTimerId = setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT)
}
onReceiveTimeout() {
console.warn('接收消息超时')
this.discardWebsocket()
}
discardWebsocket() {
if (this.receiveTimeoutTimerId) {
clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
this.onWsClose()
}
onWsClose() {
this.websocket = null
if (this.heartbeatTimerId) {
clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
if (this.receiveTimeoutTimerId) {
clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
if (this.isDestroying) {
return
}
this.retryCount++
console.warn('心跳超时, 重连中', this.retryCount)
setTimeout(this.wsConnect.bind(this), 1000)
}
onWsMessage(event) {
if (!(event.data instanceof ArrayBuffer)) {
console.warn('未知的websocket消息类型data=', event.data)
return
}
const data = new Uint8Array(event.data)
this.parseWsMessage(data)
// 至少成功处理1条消息
this.retryCount = 0
}
parseWsMessage(data) {
let offset = 0
let dataView = new DataView(data.buffer)
let packLen = dataView.getUint32(0)
let rawHeaderSize = dataView.getUint16(4)
// let ver = dataView.getUint16(6)
const operation = dataView.getUint32(8)
// let seqId = dataView.getUint32(12)
switch (operation) {
case OP_AUTH_REPLY:
case OP_SEND_MSG_REPLY: {
// 业务消息,可能有多个包一起发,需要分包
while (true) {
// eslint-disable-line no-constant-condition
const body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
this.parseBusinessMessage(dataView, body)
offset += packLen
if (offset >= data.byteLength) {
break
}
dataView = new DataView(data.buffer, offset)
packLen = dataView.getUint32(0)
rawHeaderSize = dataView.getUint16(4)
}
break
}
case OP_HEARTBEAT_REPLY: {
// 服务器心跳包,包含人气值,这里没用
this.refreshReceiveTimeoutTimer()
break
}
default: {
// 未知消息
const body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
console.warn('未知包类型operation=', operation, dataView, body)
break
}
}
}
parseBusinessMessage(dataView, body) {
const ver = dataView.getUint16(6)
const operation = dataView.getUint32(8)
switch (operation) {
case OP_SEND_MSG_REPLY: {
// 业务消息
if (ver == WS_BODY_PROTOCOL_VERSION_BROTLI) {
// 压缩过的先解压
body = BrotliDecode(body)
this.parseWsMessage(body)
} /*else if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) {
// web端已经不用zlib压缩了但是开放平台会用
body = inflate(body)
this.parseWsMessage(body)
}*/ else {
// 没压缩过的直接反序列化
if (body.length !== 0) {
try {
const text = textDecoder.decode(body)
this.onRawMessage(text)
this.CMD_CALLBACK_MAP['RAW_MESSAGE']?.call(this, text)
body = JSON.parse(text)
this.handlerCommand(body)
} catch (e) {
console.error('body=', body)
throw e
}
}
}
break
}
case OP_AUTH_REPLY: {
// 认证响应
body = JSON.parse(textDecoder.decode(body))
if (body.code !== AUTH_REPLY_CODE_OK) {
console.error('认证响应错误body=', body)
this.needInitRoom = true
this.discardWebsocket()
throw new Error('认证响应错误')
}
this.sendHeartbeat()
break
}
default: {
// 未知消息
console.warn('未知包类型operation=', operation, dataView, body)
break
}
}
}
onRawMessage(command) {}
handlerCommand(command) {
let cmd = command.cmd || ''
const pos = cmd.indexOf(':')
if (pos != -1) {
cmd = cmd.substr(0, pos)
}
const callback = this.CMD_CALLBACK_MAP[cmd]
if (callback) {
callback.call(this, command)
}
}
}

View File

@@ -1,201 +0,0 @@
<script setup lang="ts">
import { GetSelfAccount, useAccount } from '@/api/account'
import { QueryGetAPI } from '@/api/query'
import { BILI_API_URL } from '@/data/constants'
import {
NAlert,
NButton,
NCard,
NCountdown,
NInput,
NInputGroup,
NInputNumber,
NSpace,
NSpin,
NText,
useMessage,
} from 'naive-ui'
import { onMounted, ref } from 'vue'
const message = useMessage()
const accountInfo = useAccount()
const isStart = ref(false)
const timeLeft = ref(0)
const timeOut = ref(false)
const uId = ref()
const roomId = ref()
const timer = ref()
function onStartVerify() {
QueryGetAPI(BILI_API_URL + 'verify', {
uId: uId.value,
}).then((data) => {
if (data.code == 200) {
message.info('已开始认证流程, 请前往直播间发送认证码')
checkStatus()
isStart.value = true
timer.value = setInterval(checkStatus, 2500)
}
})
}
async function checkStatus() {
const data = await QueryGetAPI<{
uId: number
roomId: number
endTime: number
}>(BILI_API_URL + 'status')
if (data.code == 200) {
//正在进行认证
roomId.value ??= data.data.roomId
timeLeft.value = data.data.endTime
return true
} else if (data.code == 201) {
clearInterval(timer.value)
message.success('认证成功')
setTimeout(() => {
GetSelfAccount()
}, 1)
return true
} else if (data.code == 400 && isStart.value) {
timeOut.value = true
clearInterval(timer.value)
message.error('认证超时')
return false
}
return false
}
function copyCode() {
if (navigator.clipboard) {
navigator.clipboard.writeText(accountInfo.value?.biliVerifyCode ?? '')
message.success('已复制认证码到剪切板')
} else {
message.warning('当前环境不支持自动复制, 请手动选择并复制')
}
}
onMounted(async () => {
if (accountInfo.value && !accountInfo.value.isBiliVerified) {
if (await checkStatus()) {
isStart.value = true
timer.value = setInterval(checkStatus, 5000)
}
}
})
</script>
<template>
<NAlert
v-if="accountInfo?.isBiliVerified"
type="success"
>
你已通过验证
</NAlert>
<NAlert v-else-if="!accountInfo">
尚未登录
</NAlert>
<NCard
v-else
embedded
>
<template #header>
Bilibili 身份验证
</template>
<template v-if="isStart">
<NSpace
vertical
justify="center"
align="center"
>
<template v-if="!timeOut">
<NSpin />
<span> 剩余 <NCountdown :duration="timeLeft - Date.now()" /> </span>
</template>
<NAlert
v-else
type="error"
>
认证超时
<NButton
type="info"
@click="
() => {
isStart = false
timeOut = false
}
"
>
重新开始
</NButton>
</NAlert>
<NInputGroup>
<NInput
v-model:value="accountInfo.biliVerifyCode"
:allow-input="() => false"
/>
<NButton @click="copyCode">
复制认证码
</NButton>
</NInputGroup>
<NButton
v-if="roomId"
type="primary"
tag="a"
:href="'https://live.bilibili.com/' + roomId"
target="_blank"
>
前往直播间
</NButton>
</NSpace>
</template>
<template v-else>
<NSpace
vertical
justify="center"
align="center"
>
<NAlert type="info">
<NText>
请在点击
<NText
type="primary"
strong
>
开始认证
</NText>
后2分钟之内使用
<NText
strong
type="primary"
>
需要认证的账户
</NText>
在自己的直播间内发送
<NButton
type="info"
text
@click="copyCode"
>
{{ accountInfo?.biliVerifyCode }}
</NButton>
</NText>
</NAlert>
<NInputNumber
v-model:value="uId"
size="small"
placeholder="输入用户UId"
:min="1"
:show-button="false"
/>
<NButton
size="large"
type="primary"
@click="onStartVerify"
>
开始认证
</NButton>
</NSpace>
</template>
</NCard>
</template>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { NAlert } from 'naive-ui'
import OpenLottery from '../open_live/OpenLottery.vue'
const accountInfo = useAccount()
const client = await useDanmakuClient().initOpenlive()
</script>
<template>
<NAlert
v-if="accountInfo?.isBiliVerified != true"
type="info"
>
尚未进行Bilibili认证
</NAlert>
<OpenLottery
v-else
:room-info="client.authInfo!"
:code="accountInfo?.biliAuthCode"
/>
</template>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { NAlert } from 'naive-ui'
import MusicRequest from '../open_live/MusicRequest.vue'
const accountInfo = useAccount()
const client = await useDanmakuClient().initOpenlive()
</script>
<template>
<NAlert
v-if="accountInfo?.isBiliVerified != true"
type="info"
>
尚未进行Bilibili认证
</NAlert>
<MusicRequest
v-else
:client="client"
:room-info="client.authInfo!"
:code="accountInfo?.biliAuthCode"
/>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
// 组件属性定义
const props = defineProps<{
// 可以根据需要添加属性
}>()
// 事件定义
const emit = defineEmits<{
// 可以根据需要添加事件
}>()
// 组件挂载时的初始化
onMounted(() => {
// 初始化逻辑
})
</script>
<template>
<div class="point-sub-item-manage">
<!-- 组件内容 -->
</div>
</template>
<style scoped>
.point-sub-item-manage {
width: 100%;
}
</style>

View File

@@ -22,7 +22,9 @@ import {
NFlex, NFlex,
NForm, NForm,
NFormItem, NFormItem,
NInput, // 引入 NInput NGrid,
NGi,
NInput,
NInputNumber, NInputNumber,
NModal, NModal,
NSelect, NSelect,
@@ -556,42 +558,50 @@ onMounted(async () => {
</NButton> </NButton>
</template> </template>
</NEmpty> </NEmpty>
<div <NGrid
v-else v-else
class="goods-grid" cols="1 500:2 750:3 1000:4 1300:5"
:x-gap="12"
:y-gap="12"
class="goods-list"
style="justify-items: center;"
> >
<PointGoodsItem <NGi
v-for="item in selectedItems" v-for="item in selectedItems"
:key="item.id" :key="item.id"
:goods="item" style="width: 100%;"
content-style="max-width: 300px; min-width: 250px; height: 380px;"
class="goods-item"
:class="{ 'pinned-item': item.isPinned }"
> >
<template #footer> <PointGoodsItem
<NFlex :goods="item"
justify="space-between" content-style="max-width: 300px; min-width: 250px; height: 380px;"
align="center" class="goods-item"
class="goods-footer" :class="{ 'pinned-item': item.isPinned }"
> >
<NTooltip placement="bottom"> <template #footer>
<template #trigger> <NFlex
<NButton justify="space-between"
:disabled="getTooltip(item) !== '开始兑换'" align="center"
size="small" class="goods-footer"
type="primary" >
class="exchange-btn" <NTooltip placement="bottom">
@click="onBuyClick(item)" <template #trigger>
> <NButton
{{ item.isPinned ? '🔥 兑换' : '兑换' }} :disabled="getTooltip(item) !== '开始兑换'"
</NButton> size="small"
</template> type="primary"
{{ getTooltip(item) }} class="exchange-btn"
</NTooltip> @click="onBuyClick(item)"
</NFlex> >
</template> {{ item.isPinned ? '🔥 兑换' : '兑换' }}
</PointGoodsItem> </NButton>
</div> </template>
{{ getTooltip(item) }}
</NTooltip>
</NFlex>
</template>
</PointGoodsItem>
</NGi>
</NGrid>
</NSpin> </NSpin>
<!-- 兑换确认模态框 --> <!-- 兑换确认模态框 -->
@@ -712,7 +722,7 @@ onMounted(async () => {
<style scoped> <style scoped>
.point-goods-container { .point-goods-container {
max-width: 1200px; max-width: 1300px;
margin: 0 auto; margin: 0 auto;
padding: 0 8px; padding: 0 8px;
} }
@@ -799,11 +809,9 @@ onMounted(async () => {
min-height: 200px; min-height: 200px;
} }
.goods-grid { .goods-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
margin-top: 16px; margin-top: 16px;
justify-items: center;
} }
.goods-item { .goods-item {
@@ -815,6 +823,7 @@ onMounted(async () => {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin: 0 auto;
} }
.goods-item:hover { .goods-item:hover {
@@ -943,10 +952,6 @@ onMounted(async () => {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.goods-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.price-text { .price-text {
font-size: 1.1em; font-size: 1.1em;
} }

View File

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

View File

@@ -1,12 +1,18 @@
<template> <template>
<div class="checkin-ranking-view"> <div class="checkin-ranking-view">
<NSpace vertical> <NSpace vertical>
<NCard title="签到排行榜"> <NCard
class="ranking-card"
title="签到排行榜"
>
<template #header-extra> <template #header-extra>
<NSpace> <NSpace
:wrap="true"
:size="8"
>
<NSelect <NSelect
v-model:value="timeRange" v-model:value="timeRange"
style="width: 180px" style="min-width: 120px; width: auto"
:options="timeRangeOptions" :options="timeRangeOptions"
@update:value="loadCheckInRanking" @update:value="loadCheckInRanking"
/> />
@@ -14,7 +20,7 @@
v-model:value="userFilter" v-model:value="userFilter"
placeholder="搜索用户" placeholder="搜索用户"
clearable clearable
style="width: 150px" style="min-width: 120px; width: auto"
/> />
<NButton <NButton
type="primary" type="primary"
@@ -38,125 +44,129 @@
<!-- 自定义排行榜表格 --> <!-- 自定义排行榜表格 -->
<div <div
v-else v-else
class="custom-ranking-table" class="ranking-table-wrapper"
> >
<!-- 排行榜头部 --> <div
<div class="ranking-header"> class="custom-ranking-table"
<div class="ranking-row"> >
<div class="col-rank"> <!-- 排行榜头部 -->
排名 <div class="ranking-header">
</div> <div class="ranking-row">
<div class="col-user"> <div class="col-rank">
用户 排名
</div> </div>
<div class="col-days"> <div class="col-user">
连续签到 用户
</div> </div>
<div class="col-monthly"> <div class="col-days">
本月签到 连续签到
</div> </div>
<div class="col-total"> <div class="col-monthly">
签到 本月签到
</div> </div>
<div class="col-time"> <div class="col-total">
最近签到时间 总签到
</div>
<div class="col-time">
最近签到时间
</div>
</div> </div>
</div> </div>
</div>
<!-- 排行榜内容 --> <!-- 排行榜内容 -->
<div class="ranking-body"> <div class="ranking-body">
<div <div
v-for="(item, index) in pagedData" v-for="(item, index) in pagedData"
:key="index" :key="index"
class="ranking-row" class="ranking-row"
:class="{'top-three': index < 3}" :class="{'top-three': index < 3}"
> >
<!-- 排名列 --> <!-- 排名列 -->
<div class="col-rank"> <div class="col-rank">
<div <div
class="rank-number" class="rank-number"
:class="{ :class="{
'rank-1': index === 0, 'rank-1': index === 0,
'rank-2': index === 1, 'rank-2': index === 1,
'rank-3': index === 2 'rank-3': index === 2
}" }"
> >
{{ index + 1 + (pagination.page - 1) * pagination.pageSize }} {{ index + 1 + (pagination.page - 1) * pagination.pageSize }}
</div>
</div> </div>
</div>
<!-- 用户列 --> <!-- 用户列 -->
<div class="col-user"> <div class="col-user">
<div class="user-name"> <div class="user-name">
{{ item.name }} {{ item.name }}
</div>
<div
v-if="item.isAuthed"
class="user-authed"
>
已认证
</div>
</div> </div>
<div
v-if="item.isAuthed"
class="user-authed"
>
已认证
</div>
</div>
<!-- 连续签到列 --> <!-- 连续签到列 -->
<div class="col-days"> <div class="col-days">
<div class="days-count"> <div class="days-count">
{{ item.consecutiveDays }} {{ item.consecutiveDays }}
</div>
<div class="days-text">
</div>
</div> </div>
<div class="days-text">
</div>
</div>
<!-- 本月签到列 --> <!-- 本月签到列 -->
<div class="col-monthly"> <div class="col-monthly">
<div class="count-value"> <div class="count-value">
{{ item.monthlyCheckInCount || 0 }} {{ item.monthlyCheckInCount || 0 }}
</div>
<div class="count-text">
</div>
</div> </div>
<div class="count-text">
</div>
</div>
<!-- 总签到列 --> <!-- 总签到列 -->
<div class="col-total"> <div class="col-total">
<div class="count-value"> <div class="count-value">
{{ item.totalCheckInCount || 0 }} {{ item.totalCheckInCount || 0 }}
</div>
<div class="count-text">
</div>
</div> </div>
<div class="count-text">
</div>
</div>
<!-- 签到时间列 --> <!-- 签到时间列 -->
<div class="col-time"> <div class="col-time">
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NTime <NTime
:time="item.lastCheckInTime" :time="item.lastCheckInTime"
type="relative" type="relative"
/> />
</template> </template>
<template #default> <template #default>
<NTime <NTime
:time="item.lastCheckInTime" :time="item.lastCheckInTime"
/> />
</template> </template>
</NTooltip> </NTooltip>
</div>
</div> </div>
</div> </div>
</div>
<!-- 分页控制 --> <!-- 分页控制 -->
<div class="ranking-footer"> <div class="ranking-footer">
<NPagination <NPagination
v-model:page="pagination.page" v-model:page="pagination.page"
v-model:page-size="pagination.pageSize" v-model:page-size="pagination.pageSize"
:item-count="filteredRankingData.length" :item-count="filteredRankingData.length"
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
show-size-picker show-size-picker
/> />
</div>
</div> </div>
</div> </div>
</NSpin> </NSpin>
@@ -353,29 +363,27 @@ onMounted(() => {
.custom-ranking-table { .custom-ranking-table {
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
/* 使用官方阴影变量 */
box-shadow: var(--box-shadow-1); box-shadow: var(--box-shadow-1);
margin-bottom: 16px; margin-bottom: 16px;
overflow-x: auto;
} }
.ranking-header { .ranking-header {
/* 使用官方背景色变量 */
background-color: var(--table-header-color); background-color: var(--table-header-color);
font-weight: var(--font-weight-strong); font-weight: var(--font-weight-strong);
color: var(--text-color-2); color: var(--text-color-2);
border-radius: 8px;
} }
.ranking-row { .ranking-row {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 16px; padding: 12px 16px;
/* 使用官方分割线变量 */
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
transition: background-color 0.3s var(--cubic-bezier-ease-in-out); transition: background-color 0.3s var(--cubic-bezier-ease-in-out);
} }
.ranking-body .ranking-row:hover { .ranking-body .ranking-row:hover {
/* 使用官方悬停背景色变量 */
background-color: var(--hover-color); background-color: var(--hover-color);
} }
@@ -384,7 +392,6 @@ onMounted(() => {
} }
.top-three { .top-three {
/* 使用官方条纹背景色变量 */
background-color: var(--table-color-striped); background-color: var(--table-color-striped);
} }
@@ -423,12 +430,10 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: var(--font-weight-strong); font-weight: var(--font-weight-strong);
/* 使用官方文本和背景色变量 */
color: var(--text-color-2); color: var(--text-color-2);
background-color: var(--action-color); background-color: var(--action-color);
} }
/* 保持奖牌颜色在暗色模式下也清晰可见 */
.rank-1 { .rank-1 {
background: linear-gradient(135deg, #ffe259, #ffa751); background: linear-gradient(135deg, #ffe259, #ffa751);
color: white !important; color: white !important;
@@ -475,7 +480,64 @@ onMounted(() => {
padding: 16px; padding: 16px;
display: flex; display: flex;
justify-content: center; justify-content: center;
/* 使用官方背景色变量 */
background-color: var(--table-header-color); background-color: var(--table-header-color);
} }
/* 增强响应式样式 */
.ranking-card :deep(.n-card-header__main) {
font-size: var(--font-size-large);
white-space: nowrap;
}
.ranking-table-wrapper {
overflow-x: auto;
}
/* 响应式调整 */
@media (max-width: 768px) {
.ranking-card :deep(.n-card-header) {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.ranking-card :deep(.n-card-header__extra) {
margin-left: 0;
width: 100%;
}
.col-rank {
width: 50px;
}
.col-days,
.col-monthly,
.col-total {
width: 80px;
}
.col-time {
width: 120px;
}
.custom-ranking-table {
min-width: 550px;
}
}
@media (max-width: 480px) {
.col-user {
min-width: 80px;
}
.col-days,
.col-monthly,
.col-total {
width: 70px;
}
.col-time {
width: 100px;
}
}
</style> </style>

View File

@@ -821,6 +821,7 @@ onUnmounted(() => {
position: relative; position: relative;
width: 100%; width: 100%;
padding: 0 16px; padding: 0 16px;
box-sizing: border-box;
} }
/* 表单卡片样式 */ /* 表单卡片样式 */

View File

@@ -1,44 +0,0 @@
<script lang="ts" setup>
import { UserInfo } from '@/api/api-models'
import { TemplateConfig } from '@/data/VTsuruConfigTypes'
import { h } from 'vue'
const width = window.innerWidth
const props = defineProps<{
userInfo: UserInfo | undefined
biliInfo: any | undefined
currentData?: any
}>()
function navigate(url: string) {
window.open(url, '_blank')
}
</script>
<script lang="ts">
export type ConfigType = {
cover?: string
}
export const Config: TemplateConfig<ConfigType> = {
name: 'Template.Index.Simple',
items: [
{
name: '封面',
type: 'image',
imageLimit: 1,
key: 'cover',
onUploaded: (url, config) => {
config.cover = url[0]
},
},
{
name: 'test',
key: 'test',
type: 'render',
render: (config) => h('div', '1'),
},
],
}
</script>
<template>1</template>