koishi-plugin-starfx-bot 0.26.4 → 0.26.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/songRoom.ejs +336 -93
- package/lib/index.d.ts +0 -11
- package/lib/index.js +5 -263
- package/package.json +2 -3
package/assets/songRoom.ejs
CHANGED
|
@@ -63,28 +63,20 @@
|
|
|
63
63
|
|
|
64
64
|
/* 离场动画:只管滑出和透明度 */
|
|
65
65
|
.slide-out-item {
|
|
66
|
-
/* 重点:这里只管透明度和动画,不要在 keyframes 里写 margin */
|
|
67
66
|
animation: slideOutToRight 0.4s cubic-bezier(0.2, 0, 0, 1) forwards;
|
|
68
|
-
|
|
69
|
-
/* 关键:用 transition 平滑缩小高度,而不是用 animation */
|
|
70
|
-
transition:
|
|
71
|
-
max-height 0.4s ease,
|
|
72
|
-
margin 0.4s ease,
|
|
73
|
-
padding 0.4s ease,
|
|
74
|
-
opacity 0.4s ease;
|
|
75
|
-
max-height: 100px;
|
|
67
|
+
transition: all 0.4s cubic-bezier(0.2, 0, 0, 1);
|
|
76
68
|
overflow: hidden;
|
|
77
|
-
pointer-events: none;
|
|
78
69
|
}
|
|
79
70
|
|
|
80
|
-
/*
|
|
71
|
+
/* 当进入删除状态时,高度坍塌 */
|
|
81
72
|
.slide-out-item[is-deleting="true"] {
|
|
82
|
-
|
|
73
|
+
max-height: 0 !important;
|
|
83
74
|
margin-bottom: 0 !important;
|
|
84
75
|
margin-top: 0 !important;
|
|
85
|
-
|
|
86
|
-
|
|
76
|
+
padding-top: 0 !important;
|
|
77
|
+
padding-bottom: 0 !important;
|
|
87
78
|
opacity: 0;
|
|
79
|
+
border-width: 0;
|
|
88
80
|
}
|
|
89
81
|
|
|
90
82
|
.song-card:hover .font-bold {
|
|
@@ -126,25 +118,33 @@
|
|
|
126
118
|
</head>
|
|
127
119
|
<body class="bg-slate-50 min-h-screen p-4 sm:p-8 text-slate-900">
|
|
128
120
|
<div id="app" class="max-w-md mx-auto" v-cloak>
|
|
129
|
-
<header class="mb-6 flex justify-between items-
|
|
121
|
+
<header class="mb-6 flex justify-between items-start">
|
|
130
122
|
<div>
|
|
131
123
|
<h1 class="text-3xl font-black text-indigo-600">KTV Queue</h1>
|
|
132
124
|
<p class="text-slate-400">房间: <%= roomId %></p>
|
|
133
125
|
</div>
|
|
134
|
-
<div class="
|
|
135
|
-
|
|
126
|
+
<div class="flex flex-col items-end gap-2">
|
|
127
|
+
<button @click="showSettings = true" class="p-2 bg-white rounded-xl shadow-sm border border-slate-200 text-slate-500 hover:text-indigo-600 transition">
|
|
128
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
129
|
+
<circle cx="12" cy="12" r="3"></circle>
|
|
130
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
131
|
+
</svg>
|
|
132
|
+
</button>
|
|
133
|
+
<div class="text-[10px] text-slate-300 font-mono bg-slate-100 px-2 py-1 rounded">
|
|
134
|
+
HASH: {{ lastHash.slice(0,6) }}
|
|
135
|
+
</div>
|
|
136
136
|
</div>
|
|
137
137
|
</header>
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
<button @click="
|
|
143
|
-
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
<div class="mb-6 px-2">
|
|
142
|
+
<button @click="showAddModal = true" class="w-full py-4 bg-indigo-600 text-white font-bold rounded-2xl hover:bg-indigo-700 active:scale-95 transition shadow-lg shadow-indigo-200 flex items-center justify-center gap-2">
|
|
143
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
|
|
144
|
+
添加新歌曲
|
|
144
145
|
</button>
|
|
145
146
|
</div>
|
|
146
147
|
|
|
147
|
-
|
|
148
148
|
<draggable
|
|
149
149
|
v-model="songs"
|
|
150
150
|
item-key="id"
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
>
|
|
158
158
|
<template #item="{ element }">
|
|
159
159
|
<div :key="element.id"
|
|
160
|
-
@click="goToLink(element
|
|
160
|
+
@click="goToLink(element)"
|
|
161
161
|
:is-deleting="element.isDeleting ? 'true' : 'false'"
|
|
162
162
|
:class="['song-card bg-white mb-3 p-4 rounded-2xl shadow-sm border border-slate-200 flex items-center group',
|
|
163
163
|
element.isNew ? 'slide-in-item' : '',
|
|
@@ -200,6 +200,34 @@
|
|
|
200
200
|
</template>
|
|
201
201
|
</draggable>
|
|
202
202
|
|
|
203
|
+
<transition name="modal">
|
|
204
|
+
<div v-if="showAddModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm" @click.self="showAddModal = false">
|
|
205
|
+
<div class="bg-white w-full max-w-sm rounded-[2.5rem] shadow-2xl p-6 space-y-4 animate-pop">
|
|
206
|
+
<h3 class="text-xl font-black text-slate-800 px-2">添加新歌曲</h3>
|
|
207
|
+
|
|
208
|
+
<div class="space-y-1">
|
|
209
|
+
<label class="text-[10px] font-bold text-indigo-400 ml-1 uppercase tracking-widest">智能提取 (粘贴B站分享文案)</label>
|
|
210
|
+
<textarea v-model="autoInput" @input="handleAutoRecognize" class="w-full px-4 py-3 bg-indigo-50/50 rounded-2xl outline-none border-2 border-transparent focus:border-indigo-200 transition text-sm h-24 resize-none" placeholder="在这里粘贴..."></textarea>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<div class="relative flex items-center justify-center py-2">
|
|
214
|
+
<div class="w-full border-t border-slate-100"></div>
|
|
215
|
+
<span class="absolute bg-white px-3 text-[10px] font-bold text-slate-300">手动核对</span>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div class="space-y-3">
|
|
219
|
+
<input v-model="form.title" class="w-full px-4 py-3 bg-slate-50 rounded-xl outline-none focus:ring-2 focus:ring-indigo-400 text-sm" placeholder="歌曲标题">
|
|
220
|
+
<input v-model="form.url" class="w-full px-4 py-3 bg-slate-50 rounded-xl outline-none focus:ring-2 focus:ring-indigo-400 text-sm" placeholder="跳转链接">
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div class="flex gap-3 pt-2">
|
|
224
|
+
<button @click="showAddModal = false" class="flex-1 py-3 bg-slate-100 text-slate-500 font-bold rounded-xl transition">取消</button>
|
|
225
|
+
<button @click="handleAdd" class="flex-1 py-3 bg-indigo-600 text-white font-bold rounded-xl shadow-lg shadow-indigo-200 transition">确认添加</button>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</transition>
|
|
230
|
+
|
|
203
231
|
<transition name="modal-fade">
|
|
204
232
|
<div v-if="editingSong" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm" @click.self="editingSong = null">
|
|
205
233
|
<div class="modal-container bg-white w-full max-w-sm rounded-3xl shadow-2xl border border-slate-100 p-6 space-y-4">
|
|
@@ -252,6 +280,71 @@
|
|
|
252
280
|
</div>
|
|
253
281
|
</transition>
|
|
254
282
|
|
|
283
|
+
<transition name="modal-fade">
|
|
284
|
+
<div v-if="pendingJumpUrl" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm" @click.self="pendingJumpUrl = null">
|
|
285
|
+
<div class="modal-container bg-white w-full max-w-sm rounded-3xl shadow-2xl border border-slate-100 p-6 space-y-6">
|
|
286
|
+
<div class="text-center">
|
|
287
|
+
<div class="w-16 h-16 bg-indigo-50 text-indigo-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
288
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
289
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
|
290
|
+
<polyline points="15 3 21 3 21 9"></polyline>
|
|
291
|
+
<line x1="10" y1="14" x2="21" y2="3"></line>
|
|
292
|
+
</svg>
|
|
293
|
+
</div>
|
|
294
|
+
<h3 class="text-xl font-bold text-slate-800">即将离开页面</h3>
|
|
295
|
+
<p class="text-slate-500 mt-2 text-sm">确认要前往播放 <span class="font-semibold text-indigo-600">"{{ jumpSongTitle }}"</span> 吗?</p>
|
|
296
|
+
</div>
|
|
297
|
+
<div class="flex space-x-3">
|
|
298
|
+
<button @click="pendingJumpUrl = null" class="flex-1 py-3 bg-slate-100 text-slate-600 font-bold rounded-xl hover:bg-slate-200 transition">
|
|
299
|
+
留在本页
|
|
300
|
+
</button>
|
|
301
|
+
<button @click="confirmJump" class="flex-1 py-3 bg-indigo-600 text-white font-bold rounded-xl hover:bg-indigo-700 shadow-lg shadow-indigo-200 transition">
|
|
302
|
+
立即前往
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</transition>
|
|
308
|
+
|
|
309
|
+
<transition name="modal-fade">
|
|
310
|
+
<div v-if="showSettings" class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm" @click.self="showSettings = false">
|
|
311
|
+
<div class="modal-container bg-white w-full max-w-sm rounded-3xl shadow-2xl border border-slate-100 p-6 space-y-6">
|
|
312
|
+
<h3 class="text-xl font-bold text-slate-800">偏好设置</h3>
|
|
313
|
+
|
|
314
|
+
<div class="space-y-4">
|
|
315
|
+
<div class="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
|
316
|
+
<div class="flex items-center justify-between mb-2">
|
|
317
|
+
<span class="font-bold text-slate-700">跳转方式</span>
|
|
318
|
+
<div class="relative flex bg-slate-200 p-1 rounded-xl w-32 h-9 overflow-hidden">
|
|
319
|
+
<div class="absolute top-1 left-1 bottom-1 w-[calc(50%-4px)] bg-white rounded-lg shadow-sm transition-transform duration-300 ease-[cubic-bezier(0.4,1.2,0.3,1)]"
|
|
320
|
+
:style="{ transform: jumpMode === 'app' ? 'translateX(100%)' : 'translateX(0)' }"></div>
|
|
321
|
+
|
|
322
|
+
<button @click="jumpMode = 'web'" class="relative z-10 flex-1 text-xs font-bold transition-colors duration-200" :class="jumpMode === 'web' ? 'text-indigo-600' : 'text-slate-500'">网页</button>
|
|
323
|
+
<button @click="jumpMode = 'app'" class="relative z-10 flex-1 text-xs font-bold transition-colors duration-200" :class="jumpMode === 'app' ? 'text-indigo-600' : 'text-slate-500'">App</button>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
<p class="text-[10px] text-slate-400">网页模式打开 H5 页面,App 尝试唤起客户端</p>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<div class="flex items-center justify-between p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
|
330
|
+
<div>
|
|
331
|
+
<div class="font-bold text-slate-700">直接跳转</div>
|
|
332
|
+
<div class="text-[10px] text-slate-400">点击歌曲后不再弹出确认框</div>
|
|
333
|
+
</div>
|
|
334
|
+
<button @click="autoJump = !autoJump"
|
|
335
|
+
:class="['w-12 h-6 rounded-full transition-colors duration-300 relative focus:outline-none flex items-center', autoJump ? 'bg-indigo-600 shadow-inner' : 'bg-slate-300']">
|
|
336
|
+
<div :class="['absolute bg-white w-4 h-4 rounded-full shadow transition-all duration-300 ease-[cubic-bezier(0.34,1.2,0.5,1)]', autoJump ? 'left-7 scale-110' : 'left-1 scale-100']"></div>
|
|
337
|
+
</button>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<button @click="showSettings = false" class="w-full py-3 bg-indigo-600 text-white font-bold rounded-xl hover:bg-indigo-700 active:scale-95 transition shadow-lg shadow-indigo-100">
|
|
342
|
+
完成
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</transition>
|
|
347
|
+
|
|
255
348
|
|
|
256
349
|
<div v-if="songs.length === 0" class="text-center py-20 text-slate-300">
|
|
257
350
|
列表是空的
|
|
@@ -264,51 +357,77 @@
|
|
|
264
357
|
components: { draggable: window.vuedraggable },
|
|
265
358
|
setup() {
|
|
266
359
|
const roomId = "<%= roomId %>"
|
|
267
|
-
const songs = ref([])
|
|
268
360
|
const EMPTY_HASH = "EMPTY_LIST_HASH"; // 与后端 getHash 函数中的占位符一致
|
|
269
|
-
const lastHash = ref(EMPTY_HASH)
|
|
270
|
-
const isDragging = ref(false)
|
|
361
|
+
const lastHash = ref(EMPTY_HASH);
|
|
362
|
+
const isDragging = ref(false);
|
|
363
|
+
const deletingSong = ref(null);
|
|
364
|
+
const editingSong = ref(null);
|
|
365
|
+
const editForm = ref({ title: '', url: '' });
|
|
366
|
+
const songs = ref([]);
|
|
271
367
|
const form = ref({ title: '', url: '' })
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
368
|
+
const pendingJumpUrl = ref(null); // 存储待跳转的 URL
|
|
369
|
+
const jumpSongTitle = ref(''); // 存储待跳转的歌曲标题
|
|
370
|
+
const showSettings = ref(false);
|
|
371
|
+
const showAddModal = ref(false);
|
|
372
|
+
const autoInput = ref('');
|
|
373
|
+
const jumpMode = ref(localStorage.getItem('ktv_jump_mode') || 'web');
|
|
374
|
+
const autoJump = ref(localStorage.getItem('ktv_auto_jump') === 'true');
|
|
375
|
+
const commitApiUrl = "api/songOperation"
|
|
376
|
+
const loadSongListUrl = "api/songListInfo"
|
|
377
|
+
|
|
378
|
+
Vue.watch(jumpMode, (val) => localStorage.setItem('ktv_jump_mode', val));
|
|
379
|
+
Vue.watch(autoJump, (val) => localStorage.setItem('ktv_auto_jump', val));
|
|
380
|
+
|
|
381
|
+
if (!window.isSecureContext) {
|
|
382
|
+
console.error("当前环境不是安全上下文,Web Crypto API 将不可用!");
|
|
383
|
+
alert("当前环境不是安全上下文,Web Crypto API 将不可用,这可能会导致一些同步的问题");
|
|
287
384
|
}
|
|
288
385
|
|
|
289
386
|
|
|
290
|
-
|
|
387
|
+
async function getHash(songs) {
|
|
388
|
+
if (!window.isSecureContext) return lastHash.value;
|
|
389
|
+
if (!songs || songs.length === 0) return "EMPTY_LIST_HASH";
|
|
390
|
+
|
|
391
|
+
// 将歌曲对象数组转为固定格式的字符串
|
|
392
|
+
const str = songs.map(s => `${s.id}:${s.title}:${s.url}`).join('|');
|
|
393
|
+
const msgBuffer = new TextEncoder().encode(str);
|
|
394
|
+
|
|
395
|
+
// 调用原生 Web Crypto API 计算哈希
|
|
396
|
+
const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgBuffer);
|
|
397
|
+
|
|
398
|
+
// 将 ArrayBuffer 转为十六进制字符串
|
|
399
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
400
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
401
|
+
}
|
|
402
|
+
|
|
291
403
|
const commitOp = async (opData) => {
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
keysToRemove.forEach(k => delete cleanSong[k]);
|
|
404
|
+
let cleanSong = null;
|
|
405
|
+
if (opData.song) {
|
|
406
|
+
const { id, title, url } = opData.song;
|
|
407
|
+
cleanSong = { id, title, url };
|
|
297
408
|
}
|
|
298
409
|
|
|
299
410
|
try {
|
|
300
|
-
const res = await fetch(
|
|
411
|
+
const res = await fetch(`${commitApiUrl}?roomId=${roomId}`, {
|
|
301
412
|
method: 'POST',
|
|
302
413
|
headers: { 'Content-Type': 'application/json' },
|
|
303
414
|
body: JSON.stringify({
|
|
304
415
|
idArrayHash: lastHash.value, // 这个 Hash 现在代表了旧列表的内容+顺序
|
|
305
|
-
|
|
416
|
+
toIndex: opData.toIndex,
|
|
306
417
|
song: cleanSong
|
|
307
418
|
})
|
|
308
419
|
}).then(r => r.json());
|
|
309
420
|
|
|
310
421
|
if (res.success) {
|
|
422
|
+
if (await getHash(songs.value) !== res.hash) await load();
|
|
311
423
|
lastHash.value = res.hash;
|
|
424
|
+
if (res.song && opData.song) {
|
|
425
|
+
const localSong = songs.value.find(s => s.id === opData.song.id);
|
|
426
|
+
if (localSong) {
|
|
427
|
+
localSong.url = res.song.url; // 此时 url 已经是 bilibili://...
|
|
428
|
+
localSong.id = res.song.id; // 此时 id 已经是 BV...
|
|
429
|
+
}
|
|
430
|
+
}
|
|
312
431
|
return true;
|
|
313
432
|
} else if (res.code === 'REJECT') {
|
|
314
433
|
// 如果被拒绝,说明前端 Hash 过时或列表已空但前端不知道
|
|
@@ -321,14 +440,107 @@
|
|
|
321
440
|
return false;
|
|
322
441
|
}
|
|
323
442
|
|
|
324
|
-
|
|
443
|
+
const handleAdd = async () => {
|
|
444
|
+
if(!form.value.title || !form.value.url) return;
|
|
445
|
+
await add();
|
|
446
|
+
showAddModal.value = false;
|
|
447
|
+
autoInput.value = ''; // 清空识别框
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const handleAutoRecognize = () => {
|
|
451
|
+
let raw = autoInput.value.trim();
|
|
452
|
+
if (!raw) return;
|
|
453
|
+
|
|
454
|
+
// 提取链接
|
|
455
|
+
const urlMatch = raw.match(/https?:\/\/(?:[a-zA-Z0-9-]+\.)?(?:bilibili\.com|b23\.tv)\/[a-zA-Z0-9/._?=-]+/i);
|
|
456
|
+
if (urlMatch) form.value.url = urlMatch[0];
|
|
457
|
+
|
|
458
|
+
// 初始清理:只去掉链接,保留所有文字和括号
|
|
459
|
+
let title = raw.replace(/https?:\/\/\S+/g, '').trim();
|
|
460
|
+
|
|
461
|
+
// "-哔哩哔哩" 的最外层括号
|
|
462
|
+
// 【内容-哔哩哔哩】 -> 内容
|
|
463
|
+
title = title.replace(/[【](.*)-哔哩哔哩[】]/i, '$1');
|
|
464
|
+
// 如果没被括号包住,也直接删掉后缀
|
|
465
|
+
title = title.replace(/-哔哩哔哩/i, '');
|
|
466
|
+
|
|
467
|
+
const blacklist = /(ニコカラ|on[ /]?vocal|off[ /]?vocal|on\/off vocal|假名|字幕|罗马音|和声伴奏|纯k投屏|自用|完整版MV|KTV字幕|KTV|Karaoke|搬运|カラオケ|nicokara|卡拉OK|歌词|分唱)/gi;
|
|
468
|
+
|
|
469
|
+
function cleanTitleNested(title) {
|
|
470
|
+
|
|
471
|
+
const brackets = { '】': '【', ']': '[', ')': '(', ')': '(', '』': '『', '」': '「' };
|
|
472
|
+
const leftBrackets = Object.values(brackets);
|
|
473
|
+
|
|
474
|
+
// 使用数组操作,方便根据索引标记删除
|
|
475
|
+
let chars = title.split('');
|
|
476
|
+
|
|
477
|
+
// 辅助函数:检查一段区间是否已经被删除(全部为空字符串)
|
|
478
|
+
const isAlreadyDeleted = (start, end) => {
|
|
479
|
+
return chars.slice(start, end + 1).every(c => c === "");
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
let stack = [];
|
|
483
|
+
for (let i = 0; i < chars.length; i++) {
|
|
484
|
+
let char = chars[i];
|
|
485
|
+
|
|
486
|
+
if (leftBrackets.includes(char)) {
|
|
487
|
+
stack.push({ type: char, index: i });
|
|
488
|
+
} else if (brackets[char]) {
|
|
489
|
+
// 查找栈中最近的匹配左括号
|
|
490
|
+
let lastMatchIdx = -1;
|
|
491
|
+
for (let j = stack.length - 1; j >= 0; j--) {
|
|
492
|
+
if (stack[j].type === brackets[char]) {
|
|
493
|
+
lastMatchIdx = j;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (lastMatchIdx !== -1) {
|
|
499
|
+
let left = stack.splice(lastMatchIdx, 1)[0];
|
|
500
|
+
let start = left.index;
|
|
501
|
+
let end = i;
|
|
502
|
+
|
|
503
|
+
// 提取当前层级的内容进行检测
|
|
504
|
+
// 注意:这里需要拿 chars 里的实时内容,如果子层被删了,子层位置会是空字符
|
|
505
|
+
let currentContent = chars.slice(start, end + 1).join('');
|
|
506
|
+
|
|
507
|
+
blacklist.lastIndex = 0;
|
|
508
|
+
if (blacklist.test(currentContent)) {
|
|
509
|
+
// 命中黑名单,将 chars 数组对应区间全部置为空字符串
|
|
510
|
+
for (let k = start; k <= end; k++) {
|
|
511
|
+
chars[k] = "";
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 合并结果,并清理多余空格
|
|
519
|
+
let result = chars.join('');
|
|
520
|
+
|
|
521
|
+
// 清理“空括号”:匹配任何类型的左括号 + 0或多个空格 + 匹配的右括号
|
|
522
|
+
// 这里的正则涵盖了:(), [], {}, 【】, (), 『』, 「」
|
|
523
|
+
const emptyBrackets = /(\(\s*\)|\[\s*\]|【\s*】|(\s*)|『\s*』|「\s*」)/g;
|
|
524
|
+
|
|
525
|
+
// 循环清理,防止出现 [【】] 这种嵌套空括号清理不干净的情况
|
|
526
|
+
while (emptyBrackets.test(result)) {
|
|
527
|
+
result = result.replace(emptyBrackets, '');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 最后清理多余空格并返回
|
|
531
|
+
return result.replace(/\s+/g, ' ').trim();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
title = cleanTitleNested(title);
|
|
535
|
+
|
|
536
|
+
// 6. 清理残留在括号外的游离标签
|
|
537
|
+
title = title.replace(blacklist, "");
|
|
538
|
+
form.value.title = title;
|
|
539
|
+
};
|
|
540
|
+
|
|
325
541
|
const add = async () => {
|
|
326
542
|
let rawUrl = form.value.url.trim();
|
|
327
543
|
if(!form.value.title || !rawUrl) return;
|
|
328
|
-
// 自动补充协议头
|
|
329
|
-
if (!/^https?:\/\//i.test(rawUrl)) {
|
|
330
|
-
rawUrl = 'https://' + rawUrl;
|
|
331
|
-
}
|
|
332
544
|
|
|
333
545
|
// 计算有效长度(排除正在删除的)
|
|
334
546
|
const effectiveLen = songs.value.filter(s => !s.isDeleting).length;
|
|
@@ -355,10 +567,6 @@
|
|
|
355
567
|
if (!success) await load();
|
|
356
568
|
}
|
|
357
569
|
|
|
358
|
-
// 3. 修改 remove 函数,实现先离场再删除
|
|
359
|
-
// 在 setup 内定义新变量
|
|
360
|
-
const deletingSong = ref(null);
|
|
361
|
-
|
|
362
570
|
// 第一步:点击垃圾桶图标,仅记录要删除的对象并显示弹窗
|
|
363
571
|
const remove = (songObj) => {
|
|
364
572
|
deletingSong.value = songObj;
|
|
@@ -421,16 +629,15 @@
|
|
|
421
629
|
return result;
|
|
422
630
|
}
|
|
423
631
|
|
|
424
|
-
// load 函数修改
|
|
425
632
|
const load = async () => {
|
|
426
633
|
if (isDragging.value) return;
|
|
427
634
|
try {
|
|
428
|
-
const url = `${
|
|
635
|
+
const url = `${loadSongListUrl}?roomId=${roomId}&lastHash=${(await getHash(songs.value))}`;
|
|
429
636
|
const res = await fetch(url).then(r => r.json());
|
|
430
637
|
|
|
431
638
|
if (res.changed) {
|
|
432
639
|
const oldSongs = [...songs.value];
|
|
433
|
-
const newSongsData = res.list || []; //
|
|
640
|
+
const newSongsData = res.list || []; // 处理空返回
|
|
434
641
|
|
|
435
642
|
// 如果新数据就是空的,直接赋值并更新 Hash,跳过后续复杂的 LIS 计算
|
|
436
643
|
if (newSongsData.length === 0) {
|
|
@@ -439,11 +646,11 @@
|
|
|
439
646
|
return;
|
|
440
647
|
}
|
|
441
648
|
|
|
442
|
-
//
|
|
649
|
+
// 计算 ID 映射
|
|
443
650
|
const oldIdMap = new Map();
|
|
444
651
|
oldSongs.forEach((s, i) => { if (!s.isDeleting) oldIdMap.set(s.id, i); });
|
|
445
652
|
|
|
446
|
-
//
|
|
653
|
+
// 识别“主动移动”的 ID
|
|
447
654
|
const source = newSongsData.map(s => oldIdMap.has(s.id) ? oldIdMap.get(s.id) : -1);
|
|
448
655
|
const lisIndices = new Set(getLISIndices(source));
|
|
449
656
|
|
|
@@ -456,7 +663,7 @@
|
|
|
456
663
|
}
|
|
457
664
|
});
|
|
458
665
|
|
|
459
|
-
//
|
|
666
|
+
// 让删除项和“改动项”一起执行退出动画
|
|
460
667
|
const newIdSet = new Set(newSongsData.map(s => s.id));
|
|
461
668
|
oldSongs.forEach(s => {
|
|
462
669
|
// 如果是服务器删了,或者它是主动移动项,执行退出
|
|
@@ -465,7 +672,7 @@
|
|
|
465
672
|
}
|
|
466
673
|
});
|
|
467
674
|
|
|
468
|
-
//
|
|
675
|
+
// 等待退出动画完成
|
|
469
676
|
setTimeout(() => {
|
|
470
677
|
// 构建最终列表
|
|
471
678
|
songs.value = newSongsData.map((s, newIdx) => {
|
|
@@ -478,55 +685,83 @@
|
|
|
478
685
|
|
|
479
686
|
return {
|
|
480
687
|
...s,
|
|
481
|
-
//
|
|
688
|
+
// 入场动画
|
|
482
689
|
isMoved: isActiveMove,
|
|
483
|
-
isNew: (isNew || isActiveMove)
|
|
690
|
+
isNew: (isNew || isActiveMove), //&& oldSongs.length > 0,
|
|
484
691
|
isAffected: isAffected
|
|
485
692
|
};
|
|
486
693
|
});
|
|
487
694
|
|
|
488
695
|
lastHash.value = res.hash;
|
|
489
696
|
|
|
490
|
-
//
|
|
697
|
+
// 清理状态
|
|
491
698
|
setTimeout(() => {
|
|
492
699
|
songs.value.forEach(s => {
|
|
493
700
|
s.isNew = s.isAffected = false;
|
|
494
701
|
});
|
|
495
702
|
}, 600);
|
|
496
|
-
}, 350);
|
|
703
|
+
}, 350);
|
|
497
704
|
}
|
|
498
705
|
} catch (e) { console.error("Load Error:", e); }
|
|
499
706
|
};
|
|
500
707
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
708
|
+
const goToLink = (song) => {
|
|
709
|
+
if (song && song.url) {
|
|
710
|
+
pendingJumpUrl.value = /^https?:\/\//i.test(song.url) ? song.url : `https://${song.url}`;
|
|
711
|
+
jumpSongTitle.value = song.title;
|
|
712
|
+
|
|
713
|
+
if (autoJump.value) {
|
|
714
|
+
confirmJump(); // 直接执行跳转
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// 修改后的跳转函数
|
|
720
|
+
const confirmJump = () => {
|
|
721
|
+
if (pendingJumpUrl.value) {
|
|
722
|
+
const url = pendingJumpUrl.value;
|
|
723
|
+
// 匹配固定的 10 位 BV 号
|
|
724
|
+
const bvMatch = url.match(/BV[a-zA-Z0-9]{10}/i);
|
|
725
|
+
const bvId = bvMatch ? bvMatch[0] : null;
|
|
726
|
+
|
|
727
|
+
if (jumpMode.value === 'app') {
|
|
728
|
+
// --- 目标是 App ---
|
|
729
|
+
if (bvId) {
|
|
730
|
+
// 转换格式: bilibili://video/BV1234567890
|
|
731
|
+
window.location.href = `bilibili://video/${bvId}`;
|
|
732
|
+
} else {
|
|
733
|
+
window.location.href = url;
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
// --- 目标是 Web ---
|
|
737
|
+
if (bvId) {
|
|
738
|
+
// 转换格式: https://m.bilibili.com/video/BV1234567890
|
|
739
|
+
window.open(`https://m.bilibili.com/video/${bvId}`, '_blank');
|
|
740
|
+
} else {
|
|
741
|
+
window.open(url, '_blank');
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
pendingJumpUrl.value = null;
|
|
507
745
|
}
|
|
508
746
|
};
|
|
509
747
|
|
|
510
748
|
const onDragChange = async (evt) => {
|
|
511
749
|
isDragging.value = false;
|
|
512
750
|
if (evt.moved) {
|
|
513
|
-
const { element, newIndex
|
|
751
|
+
const { element, newIndex } = evt.moved;
|
|
514
752
|
// 修正向后移动时的标尺偏移
|
|
515
|
-
let targetK = newIndex;
|
|
516
|
-
if (newIndex > oldIndex) {
|
|
753
|
+
// let targetK = newIndex;
|
|
754
|
+
/* if (newIndex > oldIndex) {
|
|
517
755
|
targetK = newIndex + 1;
|
|
518
|
-
}
|
|
756
|
+
}*/
|
|
519
757
|
|
|
520
758
|
await commitOp({
|
|
521
759
|
song: element,
|
|
522
|
-
toIndex:
|
|
760
|
+
toIndex: newIndex // 发送修正后的标尺
|
|
523
761
|
});
|
|
524
762
|
}
|
|
525
763
|
}
|
|
526
764
|
|
|
527
|
-
const editingSong = ref(null); // 存储当前正在编辑的对象引用
|
|
528
|
-
const editForm = ref({ title: '', url: '' });
|
|
529
|
-
|
|
530
765
|
// 点击编辑按钮触发
|
|
531
766
|
const startEdit = (song) => {
|
|
532
767
|
editingSong.value = song;
|
|
@@ -535,14 +770,13 @@
|
|
|
535
770
|
};
|
|
536
771
|
|
|
537
772
|
const moveToTop = async (song) => {
|
|
538
|
-
//
|
|
773
|
+
// 如果已经在第一位,无需操作
|
|
539
774
|
if (songs.value[0]?.id === song.id) return;
|
|
540
775
|
|
|
541
|
-
//
|
|
542
|
-
// 我们先在本地模拟移动,触发 LIS 识别逻辑和动画
|
|
776
|
+
// 这里的逻辑与 load 中的“主动移动”一致
|
|
543
777
|
const oldIndex = songs.value.findIndex(s => s.id === song.id);
|
|
544
778
|
if (oldIndex !== -1) {
|
|
545
|
-
//
|
|
779
|
+
// 标记为正在移动
|
|
546
780
|
song.isDeleting = true;
|
|
547
781
|
|
|
548
782
|
setTimeout(async () => {
|
|
@@ -556,19 +790,19 @@
|
|
|
556
790
|
movedItem.isTop = true;
|
|
557
791
|
movedItem.isNew = true;
|
|
558
792
|
|
|
559
|
-
//
|
|
793
|
+
// 发送给后端,toIndex: 目标顺位
|
|
560
794
|
const success = await commitOp({
|
|
561
795
|
song: movedItem,
|
|
562
796
|
toIndex: 0
|
|
563
797
|
});
|
|
564
798
|
|
|
565
799
|
if (!success) {
|
|
566
|
-
await load();
|
|
800
|
+
await load();
|
|
567
801
|
} else {
|
|
568
802
|
// 成功后延迟移除高亮
|
|
569
803
|
setTimeout(() => { movedItem.isTop = false; movedItem.isNew = false; }, 1000);
|
|
570
804
|
}
|
|
571
|
-
}, 300);
|
|
805
|
+
}, 300);
|
|
572
806
|
}
|
|
573
807
|
};
|
|
574
808
|
|
|
@@ -603,15 +837,24 @@
|
|
|
603
837
|
|
|
604
838
|
|
|
605
839
|
|
|
840
|
+
|
|
841
|
+
|
|
606
842
|
let timer
|
|
607
843
|
onMounted(() => {
|
|
608
844
|
load()
|
|
609
845
|
// 每 3 秒同步一次数据
|
|
610
|
-
timer = setInterval(load,
|
|
846
|
+
timer = setInterval(load, 5000)
|
|
611
847
|
})
|
|
612
848
|
onUnmounted(() => clearInterval(timer))
|
|
613
849
|
|
|
614
|
-
return { songs, form, add, remove, onDragChange,
|
|
850
|
+
return { songs, form, add, remove, onDragChange,
|
|
851
|
+
lastHash, isDragging, goToLink, startEdit,
|
|
852
|
+
saveEdit, editingSong, editForm, moveToTop,
|
|
853
|
+
deletingSong, confirmDelete, pendingJumpUrl,
|
|
854
|
+
jumpSongTitle, confirmJump, showSettings,
|
|
855
|
+
jumpMode, autoJump,showAddModal, autoInput,
|
|
856
|
+
handleAutoRecognize, handleAdd,
|
|
857
|
+
}
|
|
615
858
|
}
|
|
616
859
|
}).mount('#app')
|
|
617
860
|
</script>
|
package/lib/index.d.ts
CHANGED
|
@@ -3,16 +3,6 @@ export declare const name = "starfx-bot";
|
|
|
3
3
|
export declare const inject: {
|
|
4
4
|
optional: string[];
|
|
5
5
|
};
|
|
6
|
-
declare module "@koishijs/cache" {
|
|
7
|
-
interface Tables {
|
|
8
|
-
ktv_room: Song[];
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
interface Song {
|
|
12
|
-
id: string;
|
|
13
|
-
title: string;
|
|
14
|
-
url?: string;
|
|
15
|
-
}
|
|
16
6
|
export declare let baseDir: string;
|
|
17
7
|
export declare let assetsDir: string;
|
|
18
8
|
export declare const starfxLogger: Logger;
|
|
@@ -64,7 +54,6 @@ export interface Config {
|
|
|
64
54
|
originImg: boolean;
|
|
65
55
|
originImgRSSUrl: string;
|
|
66
56
|
filePathToBase64: boolean;
|
|
67
|
-
ktvServer: boolean;
|
|
68
57
|
featureControl: Array<{
|
|
69
58
|
functionName: string;
|
|
70
59
|
whitelist: boolean;
|
package/lib/index.js
CHANGED
|
@@ -54,9 +54,7 @@ module.exports = __toCommonJS(src_exports);
|
|
|
54
54
|
var fs2 = __toESM(require("node:fs"));
|
|
55
55
|
var import_node_path4 = __toESM(require("node:path"));
|
|
56
56
|
var import_koishi5 = require("koishi");
|
|
57
|
-
var crypto = __toESM(require("crypto"));
|
|
58
57
|
var import_mime_types = __toESM(require("mime-types"));
|
|
59
|
-
var import_ejs = __toESM(require("ejs"));
|
|
60
58
|
|
|
61
59
|
// package.json
|
|
62
60
|
var package_default = {
|
|
@@ -65,7 +63,7 @@ var package_default = {
|
|
|
65
63
|
contributors: [
|
|
66
64
|
"StarFreedomX <starfreedomx@outlook.com>"
|
|
67
65
|
],
|
|
68
|
-
version: "0.26.
|
|
66
|
+
version: "0.26.5",
|
|
69
67
|
main: "lib/index.js",
|
|
70
68
|
typings: "lib/index.d.ts",
|
|
71
69
|
files: [
|
|
@@ -109,8 +107,7 @@ var package_default = {
|
|
|
109
107
|
"http-proxy-agent": "^7.0.2",
|
|
110
108
|
"https-proxy-agent": "^7.0.6",
|
|
111
109
|
"mime-types": "^3.0.1",
|
|
112
|
-
"rss-parser": "^3.13.0"
|
|
113
|
-
ejs: "^3.1.10"
|
|
110
|
+
"rss-parser": "^3.13.0"
|
|
114
111
|
},
|
|
115
112
|
devDependencies: {
|
|
116
113
|
"@biomejs/biome": "2.3.7",
|
|
@@ -741,7 +738,6 @@ function handleRoll(session) {
|
|
|
741
738
|
parts.push(element);
|
|
742
739
|
}
|
|
743
740
|
}
|
|
744
|
-
console.log(parts);
|
|
745
741
|
parts.shift();
|
|
746
742
|
if (!parts) return session.text(".noParam");
|
|
747
743
|
const last = session.elements[session.elements.length - 1];
|
|
@@ -773,7 +769,7 @@ function handleRoll(session) {
|
|
|
773
769
|
}
|
|
774
770
|
__name(handleRoll, "handleRoll");
|
|
775
771
|
function getPoints(session, num, noodles) {
|
|
776
|
-
if (!Number.isInteger(num) || !Number.isInteger(noodles) || num
|
|
772
|
+
if (!Number.isInteger(num) || !Number.isInteger(noodles) || num <= 0 || noodles <= 0)
|
|
777
773
|
return session.text(".invalid");
|
|
778
774
|
if (num > 20 || noodles > 1e8) return session.text(".too-many");
|
|
779
775
|
const points = Array(num).fill(0).map(() => Math.floor(Math.random() * noodles + 1));
|
|
@@ -1219,7 +1215,7 @@ __name(getXImageBase64, "getXImageBase64");
|
|
|
1219
1215
|
// src/index.ts
|
|
1220
1216
|
var name = "starfx-bot";
|
|
1221
1217
|
var inject = {
|
|
1222
|
-
optional: ["skia", "QhzySharp"
|
|
1218
|
+
optional: ["skia", "QhzySharp"]
|
|
1223
1219
|
};
|
|
1224
1220
|
var baseDir;
|
|
1225
1221
|
var assetsDir;
|
|
@@ -1301,8 +1297,7 @@ var Config = import_koishi5.Schema.intersect([
|
|
|
1301
1297
|
filePathToBase64: import_koishi5.Schema.boolean().default(false).description(
|
|
1302
1298
|
"在消息发送前检查是否有file://,如果有那么转换为base64再发送"
|
|
1303
1299
|
),
|
|
1304
|
-
originImg: import_koishi5.Schema.boolean().default(false).description("根据链接获取原图开关")
|
|
1305
|
-
ktvServer: import_koishi5.Schema.boolean().default(false).description('开启ktv web服务器,访问地址是"<a href="/songRoom">koishi地址/songRoom</a>"')
|
|
1300
|
+
originImg: import_koishi5.Schema.boolean().default(false).description("根据链接获取原图开关")
|
|
1306
1301
|
}).description("自用功能"),
|
|
1307
1302
|
import_koishi5.Schema.union([
|
|
1308
1303
|
import_koishi5.Schema.object({
|
|
@@ -1648,259 +1643,6 @@ function apply(ctx, cfg) {
|
|
|
1648
1643
|
}
|
|
1649
1644
|
});
|
|
1650
1645
|
}
|
|
1651
|
-
if (cfg.ktvServer && ctx.server) {
|
|
1652
|
-
let getHash = function(songs) {
|
|
1653
|
-
if (!songs || songs.length === 0) return "EMPTY_LIST_HASH";
|
|
1654
|
-
const str = songs.map((s) => `${s.id}:${s.title}`).join("|");
|
|
1655
|
-
return crypto.createHash("md5").update(str).digest("hex");
|
|
1656
|
-
}, songOperation = function(nowSongs, songIdArray, ops) {
|
|
1657
|
-
const latestSongMap = /* @__PURE__ */ new Map();
|
|
1658
|
-
if (Array.isArray(nowSongs)) {
|
|
1659
|
-
nowSongs.forEach((s) => s && s.id && latestSongMap.set(s.id, s));
|
|
1660
|
-
}
|
|
1661
|
-
[...ops].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)).forEach((op) => {
|
|
1662
|
-
if (op?.song?.id && op.toIndex !== -1) {
|
|
1663
|
-
latestSongMap.set(op.song.id, op.song);
|
|
1664
|
-
}
|
|
1665
|
-
});
|
|
1666
|
-
class ListNode {
|
|
1667
|
-
static {
|
|
1668
|
-
__name(this, "ListNode");
|
|
1669
|
-
}
|
|
1670
|
-
val;
|
|
1671
|
-
prev = null;
|
|
1672
|
-
next = null;
|
|
1673
|
-
constructor(val) {
|
|
1674
|
-
this.val = val;
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
const head = new ListNode("HEAD");
|
|
1678
|
-
let current = head;
|
|
1679
|
-
const idNodes = /* @__PURE__ */ new Map();
|
|
1680
|
-
const anchorNodes = /* @__PURE__ */ new Map();
|
|
1681
|
-
for (let i = 0; i <= (songIdArray?.length || 0); i++) {
|
|
1682
|
-
const anchorNode = new ListNode(i);
|
|
1683
|
-
anchorNodes.set(i, anchorNode);
|
|
1684
|
-
current.next = anchorNode;
|
|
1685
|
-
anchorNode.prev = current;
|
|
1686
|
-
current = anchorNode;
|
|
1687
|
-
if (i < songIdArray.length) {
|
|
1688
|
-
const id = songIdArray[i];
|
|
1689
|
-
if (id !== void 0 && id !== null) {
|
|
1690
|
-
const idNode = new ListNode(id);
|
|
1691
|
-
idNodes.set(id, idNode);
|
|
1692
|
-
current.next = idNode;
|
|
1693
|
-
idNode.prev = current;
|
|
1694
|
-
current = idNode;
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
|
-
ops.forEach((op) => {
|
|
1699
|
-
if (!op?.song?.id) return;
|
|
1700
|
-
const { song, toIndex } = op;
|
|
1701
|
-
let node = idNodes.get(song.id);
|
|
1702
|
-
if (node && node.prev) {
|
|
1703
|
-
const prevNode = node.prev;
|
|
1704
|
-
const nextNode = node.next;
|
|
1705
|
-
prevNode.next = nextNode;
|
|
1706
|
-
if (nextNode) {
|
|
1707
|
-
nextNode.prev = prevNode;
|
|
1708
|
-
}
|
|
1709
|
-
node.prev = null;
|
|
1710
|
-
node.next = null;
|
|
1711
|
-
}
|
|
1712
|
-
if (toIndex === -1) {
|
|
1713
|
-
idNodes.delete(song.id);
|
|
1714
|
-
return;
|
|
1715
|
-
}
|
|
1716
|
-
if (!node) {
|
|
1717
|
-
node = new ListNode(song.id);
|
|
1718
|
-
idNodes.set(song.id, node);
|
|
1719
|
-
}
|
|
1720
|
-
const targetAnchor = anchorNodes.get(toIndex);
|
|
1721
|
-
if (targetAnchor && targetAnchor.prev) {
|
|
1722
|
-
const before = targetAnchor.prev;
|
|
1723
|
-
before.next = node;
|
|
1724
|
-
node.prev = before;
|
|
1725
|
-
node.next = targetAnchor;
|
|
1726
|
-
targetAnchor.prev = node;
|
|
1727
|
-
}
|
|
1728
|
-
});
|
|
1729
|
-
const result = [];
|
|
1730
|
-
let p = head.next;
|
|
1731
|
-
while (p !== null) {
|
|
1732
|
-
if (typeof p.val === "string" && p.val !== "HEAD") {
|
|
1733
|
-
const songData = latestSongMap.get(p.val);
|
|
1734
|
-
if (songData) {
|
|
1735
|
-
result.push(songData);
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
p = p.next;
|
|
1739
|
-
}
|
|
1740
|
-
return result;
|
|
1741
|
-
};
|
|
1742
|
-
__name(getHash, "getHash");
|
|
1743
|
-
__name(songOperation, "songOperation");
|
|
1744
|
-
const templatePath = import_node_path4.default.resolve(assetsDir, "./songRoom.ejs");
|
|
1745
|
-
const templateStr = fs2.readFileSync(templatePath, "utf-8");
|
|
1746
|
-
const roomOpCache = {};
|
|
1747
|
-
const roomSongsCache = {};
|
|
1748
|
-
ctx.setInterval(() => {
|
|
1749
|
-
const now = Date.now();
|
|
1750
|
-
for (const roomId in roomOpCache) {
|
|
1751
|
-
roomOpCache[roomId] = roomOpCache[roomId].filter((log) => now - log.timestamp < 5 * 60 * 1e3);
|
|
1752
|
-
if (roomOpCache[roomId].length === 0) delete roomOpCache[roomId];
|
|
1753
|
-
}
|
|
1754
|
-
}, 5 * 60 * 1e3);
|
|
1755
|
-
ctx.server.get("/songRoom/api/:roomId", async (koaCtx) => {
|
|
1756
|
-
const { roomId } = koaCtx.params;
|
|
1757
|
-
const { lastHash: clientHash } = koaCtx.query;
|
|
1758
|
-
if (!roomSongsCache[roomId]) {
|
|
1759
|
-
const dbData = await ctx.cache.get("ktv_room", roomId);
|
|
1760
|
-
roomSongsCache[roomId] = dbData || [];
|
|
1761
|
-
}
|
|
1762
|
-
const currentSongs = roomSongsCache[roomId];
|
|
1763
|
-
const serverHash = getHash(currentSongs);
|
|
1764
|
-
if (!roomOpCache[roomId] || roomOpCache[roomId].length === 0) {
|
|
1765
|
-
roomOpCache[roomId] = [{
|
|
1766
|
-
idArray: currentSongs.map((s) => s.id),
|
|
1767
|
-
hash: serverHash,
|
|
1768
|
-
song: null,
|
|
1769
|
-
toIndex: -1,
|
|
1770
|
-
timestamp: Date.now()
|
|
1771
|
-
}];
|
|
1772
|
-
}
|
|
1773
|
-
if (clientHash && clientHash === serverHash) {
|
|
1774
|
-
return koaCtx.body = { changed: false, hash: serverHash };
|
|
1775
|
-
}
|
|
1776
|
-
koaCtx.body = {
|
|
1777
|
-
changed: true,
|
|
1778
|
-
list: currentSongs,
|
|
1779
|
-
hash: serverHash
|
|
1780
|
-
};
|
|
1781
|
-
});
|
|
1782
|
-
ctx.server.post("/songRoom/api/:roomId", async (koaCtx) => {
|
|
1783
|
-
const { roomId } = koaCtx.params;
|
|
1784
|
-
const body = koaCtx.request["body"];
|
|
1785
|
-
const { idArrayHash, song, toIndex } = body;
|
|
1786
|
-
if (!roomSongsCache[roomId]) {
|
|
1787
|
-
roomSongsCache[roomId] = await ctx.cache.get("ktv_room", roomId) || [];
|
|
1788
|
-
}
|
|
1789
|
-
if (!roomOpCache[roomId]) {
|
|
1790
|
-
roomOpCache[roomId] = [{
|
|
1791
|
-
idArray: roomSongsCache[roomId].map((s) => s.id),
|
|
1792
|
-
hash: getHash(roomSongsCache[roomId]),
|
|
1793
|
-
song: null,
|
|
1794
|
-
toIndex: -1,
|
|
1795
|
-
timestamp: Date.now()
|
|
1796
|
-
}];
|
|
1797
|
-
}
|
|
1798
|
-
const logs = roomOpCache[roomId];
|
|
1799
|
-
const hitIdx = logs.findIndex((l) => l.hash === idArrayHash);
|
|
1800
|
-
if (hitIdx === -1) {
|
|
1801
|
-
return koaCtx.body = { success: false, code: "REJECT" };
|
|
1802
|
-
}
|
|
1803
|
-
const baseLog = logs[hitIdx];
|
|
1804
|
-
const spotIds = [...baseLog.idArray];
|
|
1805
|
-
const nowSongs = [...roomSongsCache[roomId]];
|
|
1806
|
-
const currentOp = {
|
|
1807
|
-
idArray: [],
|
|
1808
|
-
hash: "",
|
|
1809
|
-
song,
|
|
1810
|
-
toIndex,
|
|
1811
|
-
timestamp: Date.now()
|
|
1812
|
-
};
|
|
1813
|
-
const laterOps = [...logs.slice(hitIdx + 1), currentOp];
|
|
1814
|
-
try {
|
|
1815
|
-
const finalSongs = songOperation(nowSongs, spotIds, laterOps);
|
|
1816
|
-
const finalIds = finalSongs.map((s) => s.id);
|
|
1817
|
-
const finalHash = getHash(finalSongs);
|
|
1818
|
-
currentOp.idArray = finalIds;
|
|
1819
|
-
currentOp.hash = finalHash;
|
|
1820
|
-
logs.push(currentOp);
|
|
1821
|
-
if (logs.length > 50) logs.shift();
|
|
1822
|
-
roomSongsCache[roomId] = finalSongs;
|
|
1823
|
-
await ctx.cache.set(`ktv_room`, roomId, finalSongs);
|
|
1824
|
-
koaCtx.body = { success: true, hash: finalHash };
|
|
1825
|
-
} catch (e) {
|
|
1826
|
-
console.error("Operation re-run failed:", e);
|
|
1827
|
-
koaCtx.body = { success: false, code: "REJECT" };
|
|
1828
|
-
}
|
|
1829
|
-
});
|
|
1830
|
-
ctx.server.get("/songRoom/:roomId", async (koaCtx) => {
|
|
1831
|
-
const { roomId } = koaCtx.params;
|
|
1832
|
-
const html = import_ejs.default.render(templateStr, {
|
|
1833
|
-
roomId,
|
|
1834
|
-
pageTitle: `KTV 房间 - ${roomId}`
|
|
1835
|
-
});
|
|
1836
|
-
koaCtx.type = "html";
|
|
1837
|
-
koaCtx.body = html;
|
|
1838
|
-
});
|
|
1839
|
-
ctx.server.get("/songRoom", async (koaCtx) => {
|
|
1840
|
-
koaCtx.type = "html";
|
|
1841
|
-
koaCtx.body = `
|
|
1842
|
-
<!DOCTYPE html>
|
|
1843
|
-
<html>
|
|
1844
|
-
<head>
|
|
1845
|
-
<meta charset="UTF-8">
|
|
1846
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1847
|
-
<title>进入 KTV 房间</title>
|
|
1848
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
1849
|
-
<style>
|
|
1850
|
-
@keyframes slideUp {
|
|
1851
|
-
from { opacity: 0; transform: translateY(20px); }
|
|
1852
|
-
to { opacity: 1; transform: translateY(0); }
|
|
1853
|
-
}
|
|
1854
|
-
.animate-pop { animation: slideUp 0.5s ease-out; }
|
|
1855
|
-
</style>
|
|
1856
|
-
</head>
|
|
1857
|
-
<body class="bg-slate-50 min-h-screen flex items-center justify-center p-6 text-slate-900">
|
|
1858
|
-
<div class="w-full max-w-sm bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 animate-pop">
|
|
1859
|
-
<header class="text-center mb-8">
|
|
1860
|
-
<h1 class="text-4xl font-black text-indigo-600 mb-2">KTV Queue</h1>
|
|
1861
|
-
<p class="text-slate-400 font-medium">输入房间号进入房间</p>
|
|
1862
|
-
</header>
|
|
1863
|
-
|
|
1864
|
-
<div class="space-y-4">
|
|
1865
|
-
<input id="roomInput" type="text" maxlength="10"
|
|
1866
|
-
class="w-full px-6 py-4 bg-slate-50 rounded-2xl text-center text-2xl font-bold tracking-widest outline-none focus:ring-4 focus:ring-indigo-100 transition-all border-2 border-transparent focus:border-indigo-400"
|
|
1867
|
-
placeholder="0000" autofocus>
|
|
1868
|
-
|
|
1869
|
-
<button onclick="joinRoom()"
|
|
1870
|
-
class="w-full py-4 bg-indigo-600 text-white text-lg font-bold rounded-2xl hover:bg-indigo-700 active:scale-95 transition-all shadow-lg shadow-indigo-100">
|
|
1871
|
-
进入房间
|
|
1872
|
-
</button>
|
|
1873
|
-
</div>
|
|
1874
|
-
|
|
1875
|
-
<p class="text-center text-slate-300 text-xs mt-8 uppercase tracking-widest font-bold">Powered by StarFreedomX</p>
|
|
1876
|
-
</div>
|
|
1877
|
-
|
|
1878
|
-
<script>
|
|
1879
|
-
function joinRoom() {
|
|
1880
|
-
const id = document.getElementById('roomInput').value.trim();
|
|
1881
|
-
if (id) {
|
|
1882
|
-
// 1. 获取当前页面的基础路径 (例如 "/songRoom" 或 "/")
|
|
1883
|
-
// 2. 移除末尾可能存在的斜杠
|
|
1884
|
-
const currentPath = window.location.pathname.replace(/\\/$/, '');
|
|
1885
|
-
|
|
1886
|
-
// 3. 拼接房间号实现跳转
|
|
1887
|
-
// 这样:
|
|
1888
|
-
// 如果是 127.0.0.1:5140/songRoom -> 会跳到 /songRoom/1145
|
|
1889
|
-
// 如果是 a.b.c -> 会跳到 /1145
|
|
1890
|
-
window.location.href = \`\${currentPath}/\${id}\`;
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
// 支持回车键跳转
|
|
1895
|
-
document.getElementById('roomInput').addEventListener('keypress', (e) => {
|
|
1896
|
-
if (e.key === 'Enter') joinRoom();
|
|
1897
|
-
});
|
|
1898
|
-
</script>
|
|
1899
|
-
</body>
|
|
1900
|
-
</html>
|
|
1901
|
-
`;
|
|
1902
|
-
});
|
|
1903
|
-
}
|
|
1904
1646
|
ctx.middleware(async (session, next) => {
|
|
1905
1647
|
const elements = session.elements;
|
|
1906
1648
|
if (cfg.openRepeat && detectControl(featureControl, session.guildId, "repeat")) {
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"contributors": [
|
|
5
5
|
"StarFreedomX <starfreedomx@outlook.com>"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.26.
|
|
7
|
+
"version": "0.26.6",
|
|
8
8
|
"main": "lib/index.js",
|
|
9
9
|
"typings": "lib/index.d.ts",
|
|
10
10
|
"files": [
|
|
@@ -48,8 +48,7 @@
|
|
|
48
48
|
"http-proxy-agent": "^7.0.2",
|
|
49
49
|
"https-proxy-agent": "^7.0.6",
|
|
50
50
|
"mime-types": "^3.0.1",
|
|
51
|
-
"rss-parser": "^3.13.0"
|
|
52
|
-
"ejs": "^3.1.10"
|
|
51
|
+
"rss-parser": "^3.13.0"
|
|
53
52
|
},
|
|
54
53
|
"devDependencies": {
|
|
55
54
|
"@biomejs/biome": "2.3.7",
|