koishi-plugin-starfx-bot 0.25.0 → 0.26.0

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.
@@ -0,0 +1,593 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= pageTitle %></title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/vuedraggable@4.1.0/dist/vuedraggable.umd.js"></script>
10
+ <script src="https://cdn.tailwindcss.com"></script>
11
+ <style>
12
+
13
+ /* 置顶成功后的特殊反馈 */
14
+ .highlight-top {
15
+ animation: pulseTop 1s ease;
16
+ }
17
+
18
+ @keyframes pulseTop {
19
+ 0% { border-color: #f97316; background-color: #fff7ed; }
20
+ 100% { border-color: #e2e8f0; background-color: white; }
21
+ }
22
+
23
+ @keyframes pulseHighlight {
24
+ 0% { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); }
25
+ 50% { border-color: #6366f1; box-shadow: 0 0 12px 4px rgba(99, 102, 241, 0.3); }
26
+ 100% { border-color: #e2e8f0; box-shadow: none; } /* 恢复到 slate-200 */
27
+ }
28
+ /* 主动变动: Indigo 强烈闪烁 */
29
+ .highlight-change {
30
+ animation: pulseHighlight 1s ease;
31
+ }
32
+
33
+ /* 被动受影响: Slate 弱闪烁 */
34
+ .highlight-affected {
35
+ animation: pulseAffected 0.8s ease;
36
+ }
37
+
38
+ @keyframes pulseHighlight {
39
+ 0% { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); }
40
+ 50% { border-color: #6366f1; box-shadow: 0 0 12px 4px rgba(99, 102, 241, 0.3); }
41
+ 100% { border-color: #e2e8f0; }
42
+ }
43
+
44
+ @keyframes pulseAffected {
45
+ 0% { background-color: #f8fafc; }
46
+ 50% { background-color: #f1f5f9; border-color: #cbd5e1; }
47
+ 100% { background-color: white; border-color: #e2e8f0; }
48
+ }
49
+ /* 拖拽位置交换的动画 */
50
+ .song-list-move {
51
+ transition: transform 0.4s cubic-bezier(0.2, 0, 0, 1) !important;
52
+ }
53
+
54
+ /* 入场动画 */
55
+ .slide-in-item {
56
+ animation: slideInFromRight 0.4s cubic-bezier(0.2, 0, 0, 1) backwards;
57
+ }
58
+
59
+ @keyframes slideInFromRight {
60
+ from { opacity: 0; transform: translateX(50px); }
61
+ to { opacity: 1; transform: translateX(0); }
62
+ }
63
+
64
+ /* 离场动画:只管滑出和透明度 */
65
+ .slide-out-item {
66
+ /* 重点:这里只管透明度和动画,不要在 keyframes 里写 margin */
67
+ 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;
76
+ overflow: hidden;
77
+ pointer-events: none;
78
+ }
79
+
80
+ /* 当进入删除状态时,触发 transition 坍塌 */
81
+ .slide-out-item[is-deleting="true"] {
82
+ /*max-height: 0 !important;*/
83
+ margin-bottom: 0 !important;
84
+ margin-top: 0 !important;
85
+ /*padding-top: 0 !important;*/
86
+ /*padding-bottom: 0 !important;*/
87
+ opacity: 0;
88
+ }
89
+
90
+ .song-card:hover .font-bold {
91
+ color: #4f46e5; /* indigo-600 */
92
+ text-decoration: underline;
93
+ }
94
+
95
+ @keyframes slideOutToRight {
96
+ from { opacity: 1; transform: translateX(0); }
97
+ to { opacity: 0; transform: translateX(100px); }
98
+ }
99
+
100
+ /* 弹窗进入/离开的过渡 */
101
+ .modal-fade-enter-active,
102
+ .modal-fade-leave-active {
103
+ transition: all 0.3s ease;
104
+ }
105
+
106
+ /* 初始/结束状态:遮罩透明度为0,内容缩小并稍微向下偏移 */
107
+ .modal-fade-enter-from,
108
+ .modal-fade-leave-to {
109
+ opacity: 0;
110
+ }
111
+
112
+ .modal-fade-enter-from .modal-container,
113
+ .modal-fade-leave-to .modal-container {
114
+ transform: scale(0.9) translateY(20px);
115
+ opacity: 0;
116
+ }
117
+
118
+ /* 容器本身的过渡需要独立于遮罩,这样才有层次感 */
119
+ .modal-container {
120
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
121
+ }
122
+
123
+ .ghost-card { opacity: 0.2; background: #6366f1 !important; }
124
+ .drag-handle { cursor: grab; }
125
+ </style>
126
+ </head>
127
+ <body class="bg-slate-50 min-h-screen p-4 sm:p-8 text-slate-900">
128
+ <div id="app" class="max-w-md mx-auto" v-cloak>
129
+ <header class="mb-6 flex justify-between items-end">
130
+ <div>
131
+ <h1 class="text-3xl font-black text-indigo-600">KTV Queue</h1>
132
+ <p class="text-slate-400">房间: <%= roomId %></p>
133
+ </div>
134
+ <div class="text-[10px] text-slate-300 font-mono bg-slate-100 px-2 py-1 rounded">
135
+ HASH: {{ lastHash.slice(0,6) }}
136
+ </div>
137
+ </header>
138
+
139
+ <div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-200 mb-6 space-y-3">
140
+ <input v-model="form.title" @keyup.enter="add" class="w-full px-4 py-2 bg-slate-50 rounded-xl outline-none focus:ring-2 focus:ring-indigo-400 transition" placeholder="歌曲名称">
141
+ <input v-model="form.url" @keyup.enter="add" class="w-full px-4 py-2 bg-slate-50 rounded-xl outline-none focus:ring-2 focus:ring-indigo-400 transition" placeholder="链接 (必填)">
142
+ <button @click="add" class="w-full py-2 bg-indigo-600 text-white font-bold rounded-xl hover:bg-indigo-700 active:scale-95 transition shadow-lg shadow-indigo-100">
143
+ 确认添加
144
+ </button>
145
+ </div>
146
+
147
+
148
+ <draggable
149
+ v-model="songs"
150
+ item-key="id"
151
+ handle=".drag-handle"
152
+ ghost-class="ghost-card"
153
+ :animation="300"
154
+ @start="isDragging = true"
155
+ @end="isDragging = false"
156
+ @change="onDragChange"
157
+ >
158
+ <template #item="{ element }">
159
+ <div :key="element.id"
160
+ @click="goToLink(element.url)"
161
+ :is-deleting="element.isDeleting ? 'true' : 'false'"
162
+ :class="['song-card bg-white mb-3 p-4 rounded-2xl shadow-sm border border-slate-200 flex items-center group',
163
+ element.isNew ? 'slide-in-item' : '',
164
+ element.isDeleting ? 'slide-out-item' : '',
165
+ element.isMoved ? 'highlight-change' : '',
166
+ element.isTop ? 'highlight-top' : '',
167
+ element.isAffected ? 'highlight-affected' : '']">
168
+
169
+ <div class="drag-handle p-2 mr-3 text-slate-300 hover:text-indigo-500 transition" @click.stop>
170
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
171
+ <line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line>
172
+ </svg>
173
+ </div>
174
+
175
+ <div class="flex-1 min-w-0">
176
+ <div class="font-bold text-slate-700 truncate group-hover:text-indigo-600 transition">{{ element.title }}</div>
177
+ <div class="text-xs text-slate-400 truncate">{{ element.url }}</div>
178
+ </div>
179
+
180
+ <button @click.stop="moveToTop(element)" class="p-2 text-slate-300 hover:text-orange-500 transition" title="置顶">
181
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
182
+ <path d="M12 19V5M5 12l7-7 7 7"/>
183
+ </svg>
184
+ </button>
185
+
186
+ <button @click.stop="startEdit(element)" class="p-2 text-slate-300 hover:text-indigo-500 transition">
187
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
188
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
189
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
190
+ </svg>
191
+ </button>
192
+
193
+ <button @click.stop="remove(element)" class="p-2 text-slate-200 hover:text-red-500 transition">
194
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
195
+ <polyline points="3 6 5 6 21 6"></polyline>
196
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
197
+ </svg>
198
+ </button>
199
+ </div>
200
+ </template>
201
+ </draggable>
202
+
203
+ <transition name="modal-fade">
204
+ <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
+ <div class="modal-container bg-white w-full max-w-sm rounded-3xl shadow-2xl border border-slate-100 p-6 space-y-4">
206
+ <h3 class="text-xl font-bold text-slate-800">编辑歌曲信息</h3>
207
+
208
+ <div class="space-y-3">
209
+ <div>
210
+ <label class="text-xs font-bold text-slate-400 ml-1 uppercase">歌曲名称</label>
211
+ <input v-model="editForm.title" class="w-full px-4 py-3 bg-slate-50 rounded-xl outline-none focus:ring-2 focus:ring-indigo-400 transition" placeholder="输入标题...">
212
+ </div>
213
+ <div>
214
+ <label class="text-xs font-bold text-slate-400 ml-1 uppercase">跳转链接</label>
215
+ <input v-model="editForm.url" class="w-full px-4 py-3 bg-slate-50 rounded-xl outline-none focus:ring-2 focus:ring-indigo-400 transition" placeholder="https://...">
216
+ </div>
217
+ </div>
218
+
219
+ <div class="flex space-x-3 pt-2">
220
+ <button @click="editingSong = null" class="flex-1 py-3 bg-slate-100 text-slate-600 font-bold rounded-xl hover:bg-slate-200 transition">
221
+ 取消
222
+ </button>
223
+ <button @click="saveEdit" class="flex-1 py-3 bg-indigo-600 text-white font-bold rounded-xl hover:bg-indigo-700 shadow-lg shadow-indigo-200 transition">
224
+ 保存修改
225
+ </button>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </transition>
230
+
231
+ <transition name="modal-fade">
232
+ <div v-if="deletingSong" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm" @click.self="deletingSong = null">
233
+ <div class="modal-container bg-white w-full max-w-sm rounded-3xl shadow-2xl border border-slate-100 p-6 space-y-6">
234
+ <div class="text-center">
235
+ <div class="w-16 h-16 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto mb-4">
236
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
237
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
238
+ </svg>
239
+ </div>
240
+ <h3 class="text-xl font-bold text-slate-800">确认删除?</h3>
241
+ <p class="text-slate-500 mt-2">歌曲 <span class="font-semibold text-slate-700">"{{ deletingSong.title }}"</span> 将被移除。</p>
242
+ </div>
243
+ <div class="flex space-x-3">
244
+ <button @click="deletingSong = null" class="flex-1 py-3 bg-slate-100 text-slate-600 font-bold rounded-xl hover:bg-slate-200 transition">
245
+ 返回
246
+ </button>
247
+ <button @click="confirmDelete" class="flex-1 py-3 bg-red-500 text-white font-bold rounded-xl hover:bg-red-600 transition">
248
+ 确认移除
249
+ </button>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </transition>
254
+
255
+
256
+ <div v-if="songs.length === 0" class="text-center py-20 text-slate-300">
257
+ 列表是空的
258
+ </div>
259
+ </div>
260
+
261
+ <script>
262
+ const { createApp, ref, onMounted, onUnmounted } = Vue
263
+ createApp({
264
+ components: { draggable: window.vuedraggable },
265
+ setup() {
266
+ const roomId = "<%= roomId %>"
267
+ const songs = ref([])
268
+ const lastHash = ref("")
269
+ const isDragging = ref(false)
270
+ const form = ref({ title: '', url: '' })
271
+
272
+ // 1. 修改 commitOp,防止前端标记传给后端导致 500 错误
273
+ const commitOp = async (opData) => {
274
+ const cleanSong = opData.song ? { ...opData.song } : null;
275
+ if (cleanSong) {
276
+ // 剔除所有前端动画状态标记,防止后端校验失败或存储多余字段
277
+ const keysToRemove = ['isNew', 'isDeleting', 'isMoved', 'isAffected'];
278
+ keysToRemove.forEach(k => delete cleanSong[k]);
279
+ }
280
+
281
+ try {
282
+ const res = await fetch(`/songRoom/api/${roomId}`, {
283
+ method: 'POST',
284
+ headers: { 'Content-Type': 'application/json' },
285
+ body: JSON.stringify({
286
+ idArrayHash: lastHash.value, // 这个 Hash 现在代表了旧列表的内容+顺序
287
+ ...opData,
288
+ song: cleanSong
289
+ })
290
+ }).then(r => r.json());
291
+
292
+ if (res.success) {
293
+ lastHash.value = res.hash; // 得到包含新 title/url 的新 Hash
294
+ return true;
295
+ } else if (res.code === 'REJECT') {
296
+ lastHash.value = ""; // Hash 冲突,强制 load 最新数据
297
+ await load();
298
+ }
299
+ } catch (e) {
300
+ console.error("API Error:", e);
301
+ }
302
+ return false;
303
+ }
304
+
305
+ // 2. 修改 add 函数,计算正确的 toIndex
306
+ const add = async () => {
307
+ let rawUrl = form.value.url.trim();
308
+ if(!form.value.title || !rawUrl) return;
309
+ // 自动补充协议头
310
+ if (!/^https?:\/\//i.test(rawUrl)) {
311
+ rawUrl = 'https://' + rawUrl;
312
+ }
313
+
314
+ // 计算有效长度(排除正在删除的)
315
+ const effectiveLen = songs.value.filter(s => !s.isDeleting).length;
316
+
317
+ const newSong = {
318
+ id: 's-' + Math.random().toString(36).slice(2, 11),
319
+ title: form.value.title,
320
+ url: rawUrl,
321
+ isNew: true
322
+ };
323
+
324
+ songs.value.push(newSong);
325
+ form.value = { title: '', url: '' };
326
+
327
+ setTimeout(() => {
328
+ const target = songs.value.find(s => s.id === newSong.id);
329
+ if (target) target.isNew = false;
330
+ }, 600);
331
+
332
+ const success = await commitOp({
333
+ song: newSong,
334
+ toIndex: effectiveLen // 使用排除删除项后的索引
335
+ });
336
+ if (!success) await load();
337
+ }
338
+
339
+ // 3. 修改 remove 函数,实现先离场再删除
340
+ // 在 setup 内定义新变量
341
+ const deletingSong = ref(null);
342
+
343
+ // 第一步:点击垃圾桶图标,仅记录要删除的对象并显示弹窗
344
+ const remove = (songObj) => {
345
+ deletingSong.value = songObj;
346
+ };
347
+
348
+ // 第二步:用户在弹窗点击“确认移除”
349
+ const confirmDelete = async () => {
350
+ const songObj = deletingSong.value;
351
+ if (!songObj) return;
352
+
353
+ // 先关闭弹窗,确保动画视觉焦点回到列表
354
+ deletingSong.value = null;
355
+
356
+ // 执行你原本的平滑删除逻辑
357
+ songObj.isDeleting = true;
358
+
359
+ setTimeout(async () => {
360
+ songs.value = songs.value.filter(s => s.id !== songObj.id);
361
+ await commitOp({
362
+ song: songObj,
363
+ toIndex: -1
364
+ });
365
+ }, 400); // 调整为 400ms 以匹配 CSS 坍塌速度
366
+ };
367
+
368
+ // 获取最长递增子序列的索引下标
369
+ function getLISIndices(arr) {
370
+ const p = arr.slice();
371
+ const result = [0];
372
+ let i, j, u, v, c;
373
+ const len = arr.length;
374
+ for (i = 0; i < len; i++) {
375
+ const arrI = arr[i];
376
+ if (arrI !== -1) {
377
+ j = result[result.length - 1];
378
+ if (arr[j] < arrI) {
379
+ p[i] = j;
380
+ result.push(i);
381
+ continue;
382
+ }
383
+ u = 0;
384
+ v = result.length - 1;
385
+ while (u < v) {
386
+ c = (u + v) >> 1;
387
+ if (arr[result[c]] < arrI) u = c + 1;
388
+ else v = c;
389
+ }
390
+ if (arrI < arr[result[u]]) {
391
+ if (u > 0) p[i] = result[u - 1];
392
+ result[u] = i;
393
+ }
394
+ }
395
+ }
396
+ u = result.length;
397
+ v = result[u - 1];
398
+ while (u-- > 0) {
399
+ result[u] = v;
400
+ v = p[v];
401
+ }
402
+ return result;
403
+ }
404
+
405
+ // load 函数修改
406
+ const load = async () => {
407
+ if (isDragging.value) return;
408
+ try {
409
+ const url = `/songRoom/api/${roomId}?lastHash=${lastHash.value}`;
410
+ const res = await fetch(url).then(r => r.json());
411
+
412
+ if (res.changed) {
413
+ const oldSongs = [...songs.value];
414
+ const newSongsData = res.list;
415
+
416
+ // 1. 计算 ID 映射
417
+ const oldIdMap = new Map();
418
+ oldSongs.forEach((s, i) => { if (!s.isDeleting) oldIdMap.set(s.id, i); });
419
+
420
+ // 2. 识别“主动移动”的 ID
421
+ const source = newSongsData.map(s => oldIdMap.has(s.id) ? oldIdMap.get(s.id) : -1);
422
+ const lisIndices = new Set(getLISIndices(source));
423
+
424
+ const activeMoveIds = new Set();
425
+ newSongsData.forEach((s, newIdx) => {
426
+ const oldIdx = oldIdMap.get(s.id);
427
+ // 只有既不在 LIS 里、又不是真正的新歌,才是我们要处理的“改动元素”
428
+ if (oldIdx !== undefined && oldIdx !== newIdx && !lisIndices.has(newIdx)) {
429
+ activeMoveIds.add(s.id);
430
+ }
431
+ });
432
+
433
+ // 3. 第一阶段:让删除项和“改动项”一起执行退出动画
434
+ const newIdSet = new Set(newSongsData.map(s => s.id));
435
+ oldSongs.forEach(s => {
436
+ // 如果是服务器删了,或者它是主动移动项,执行退出
437
+ if (!newIdSet.has(s.id) || activeMoveIds.has(s.id)) {
438
+ s.isDeleting = true;
439
+ }
440
+ });
441
+
442
+ // 4. 第二阶段:等待退出动画完成
443
+ setTimeout(() => {
444
+ // 构建最终列表
445
+ songs.value = newSongsData.map((s, newIdx) => {
446
+ const oldIdx = oldIdMap.get(s.id);
447
+ const isNew = oldIdx === undefined;
448
+ const isActiveMove = activeMoveIds.has(s.id);
449
+
450
+ // 被动移动的判定(在 LIS 里但位置变了)
451
+ const isAffected = !isNew && !isActiveMove && oldIdx !== newIdx;
452
+
453
+ return {
454
+ ...s,
455
+ // 如果是真新歌,或者是我们刚才主动剔除的改动项,施加入场动画
456
+ isMoved: isActiveMove,
457
+ isNew: (isNew || isActiveMove) && oldSongs.length > 0,
458
+ isAffected: isAffected
459
+ };
460
+ });
461
+
462
+ lastHash.value = res.hash;
463
+
464
+ // 5. 第三阶段:清理状态
465
+ setTimeout(() => {
466
+ songs.value.forEach(s => {
467
+ s.isNew = s.isAffected = false;
468
+ });
469
+ }, 600);
470
+ }, 350); // 这里的 400ms 对应你 CSS 里 slide-out 的时间
471
+ }
472
+ } catch (e) { console.error("Load Error:", e); }
473
+ };
474
+
475
+ // 在 setup() 内部定义
476
+ const goToLink = (url) => {
477
+ if (url) {
478
+ // 确保 URL 带有协议头
479
+ const link = /^https?:\/\//i.test(url) ? url : `https://${url}`;
480
+ window.open(link, '_blank');
481
+ }
482
+ };
483
+
484
+ const onDragChange = async (evt) => {
485
+ isDragging.value = false;
486
+ if (evt.moved) {
487
+ const { element, newIndex, oldIndex } = evt.moved;
488
+ // 修正向后移动时的标尺偏移
489
+ let targetK = newIndex;
490
+ if (newIndex > oldIndex) {
491
+ targetK = newIndex + 1;
492
+ }
493
+
494
+ await commitOp({
495
+ song: element,
496
+ toIndex: targetK // 发送修正后的标尺
497
+ });
498
+ }
499
+ }
500
+
501
+ const editingSong = ref(null); // 存储当前正在编辑的对象引用
502
+ const editForm = ref({ title: '', url: '' });
503
+
504
+ // 点击编辑按钮触发
505
+ const startEdit = (song) => {
506
+ editingSong.value = song;
507
+ // 深拷贝一份数据给表单,防止未保存就直接修改了列表
508
+ editForm.value = { title: song.title, url: song.url };
509
+ };
510
+
511
+ const moveToTop = async (song) => {
512
+ // 1. 如果已经在第一位,无需操作
513
+ if (songs.value[0]?.id === song.id) return;
514
+
515
+ // 2. 这里的逻辑与 load 中的“主动移动”一致
516
+ // 我们先在本地模拟移动,触发 LIS 识别逻辑和动画
517
+ const oldIndex = songs.value.findIndex(s => s.id === song.id);
518
+ if (oldIndex !== -1) {
519
+ // 标记为正在移动,以便 load 函数能正确处理动画(如果此时刚好触发 load)
520
+ song.isDeleting = true;
521
+
522
+ setTimeout(async () => {
523
+ // 从原位置移除
524
+ const [movedItem] = songs.value.splice(oldIndex, 1);
525
+ // 插入到最前面
526
+ songs.value.unshift(movedItem);
527
+
528
+ // 重置状态并触发高亮
529
+ movedItem.isDeleting = false;
530
+ movedItem.isTop = true;
531
+ movedItem.isNew = true;
532
+
533
+ // 3. 发送给后端,toIndex: 0 代表第 0 个锚点(首位)
534
+ const success = await commitOp({
535
+ song: movedItem,
536
+ toIndex: 0
537
+ });
538
+
539
+ if (!success) {
540
+ await load(); // 失败则重载列表
541
+ } else {
542
+ // 成功后延迟移除高亮
543
+ setTimeout(() => { movedItem.isTop = false; movedItem.isNew = false; }, 1000);
544
+ }
545
+ }, 300); // 这里的延迟为了配合 CSS 离场感
546
+ }
547
+ };
548
+
549
+ // 保存逻辑
550
+ const saveEdit = async () => {
551
+ if (!editForm.value.title || !editForm.value.url) return;
552
+
553
+ const song = editingSong.value;
554
+ const index = songs.value.findIndex(s => s.id === song.id);
555
+
556
+ if (index !== -1) {
557
+ const oldData = { title: song.title, url: song.url };
558
+
559
+ // 乐观更新 UI
560
+ song.title = editForm.value.title;
561
+ song.url = editForm.value.url;
562
+
563
+ const success = await commitOp({
564
+ song: song,
565
+ toIndex: index // 原位覆盖更新
566
+ });
567
+
568
+ if (success) {
569
+ editingSong.value = null;
570
+ } else {
571
+ // 失败回退
572
+ song.title = oldData.title;
573
+ song.url = oldData.url;
574
+ }
575
+ }
576
+ };
577
+
578
+
579
+
580
+ let timer
581
+ onMounted(() => {
582
+ load()
583
+ // 每 3 秒同步一次数据
584
+ timer = setInterval(load, 3000)
585
+ })
586
+ onUnmounted(() => clearInterval(timer))
587
+
588
+ return { songs, form, add, remove, onDragChange, lastHash, isDragging, goToLink, startEdit, saveEdit, editingSong, editForm, moveToTop, deletingSong, confirmDelete }
589
+ }
590
+ }).mount('#app')
591
+ </script>
592
+ </body>
593
+ </html>
package/lib/index.d.ts CHANGED
@@ -3,6 +3,16 @@ 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
+ }
6
16
  export declare let baseDir: string;
7
17
  export declare let assetsDir: string;
8
18
  export declare const starfxLogger: Logger;
@@ -54,6 +64,7 @@ export interface Config {
54
64
  originImg: boolean;
55
65
  originImgRSSUrl: string;
56
66
  filePathToBase64: boolean;
67
+ ktvServer: boolean;
57
68
  featureControl: Array<{
58
69
  functionName: string;
59
70
  whitelist: boolean;
package/lib/index.js CHANGED
@@ -54,7 +54,9 @@ 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"));
57
58
  var import_mime_types = __toESM(require("mime-types"));
59
+ var import_ejs = __toESM(require("ejs"));
58
60
 
59
61
  // package.json
60
62
  var package_default = {
@@ -63,7 +65,7 @@ var package_default = {
63
65
  contributors: [
64
66
  "StarFreedomX <starfreedomx@outlook.com>"
65
67
  ],
66
- version: "0.25.0",
68
+ version: "0.26.0",
67
69
  main: "lib/index.js",
68
70
  typings: "lib/index.d.ts",
69
71
  files: [
@@ -107,13 +109,15 @@ var package_default = {
107
109
  "http-proxy-agent": "^7.0.2",
108
110
  "https-proxy-agent": "^7.0.6",
109
111
  "mime-types": "^3.0.1",
110
- "rss-parser": "^3.13.0"
112
+ "rss-parser": "^3.13.0",
113
+ ejs: "^3.1.10"
111
114
  },
112
115
  devDependencies: {
113
116
  "@biomejs/biome": "2.3.7",
114
117
  "@ltxhhz/koishi-plugin-skia-canvas": "^0.0.10",
115
118
  "@quanhuzeyu/koishi-plugin-qhzy-sharp": "^1.2.1",
116
- "@quanhuzeyu/sharp-for-koishi": "^0.0.7"
119
+ "@quanhuzeyu/sharp-for-koishi": "^0.0.7",
120
+ "@types/mime-types": "^3.0.1"
117
121
  }
118
122
  };
119
123
 
@@ -1215,7 +1219,7 @@ __name(getXImageBase64, "getXImageBase64");
1215
1219
  // src/index.ts
1216
1220
  var name = "starfx-bot";
1217
1221
  var inject = {
1218
- optional: ["skia", "QhzySharp"]
1222
+ optional: ["skia", "QhzySharp", "server", "cache"]
1219
1223
  };
1220
1224
  var baseDir;
1221
1225
  var assetsDir;
@@ -1231,26 +1235,6 @@ var usage = `
1231
1235
  <li>bdbd</li>
1232
1236
  `;
1233
1237
  var repeatContextMap = /* @__PURE__ */ new Map();
1234
- var functionNames = [
1235
- "lock",
1236
- "sold",
1237
- "repeat",
1238
- "record",
1239
- "record-push",
1240
- "record-get",
1241
- "atNotSay",
1242
- "replyBot",
1243
- "iLoveYou",
1244
- "bdbd",
1245
- "roll",
1246
- "undo",
1247
- "echo",
1248
- "originImg",
1249
- "sendLocalImage",
1250
- "forward",
1251
- "exchangeRate",
1252
- "myId"
1253
- ];
1254
1238
  var Config = import_koishi5.Schema.intersect([
1255
1239
  import_koishi5.Schema.object({
1256
1240
  openLock: import_koishi5.Schema.boolean().default(true).description("开启明日方舟封印功能"),
@@ -1317,7 +1301,8 @@ var Config = import_koishi5.Schema.intersect([
1317
1301
  filePathToBase64: import_koishi5.Schema.boolean().default(false).description(
1318
1302
  "在消息发送前检查是否有file://,如果有那么转换为base64再发送"
1319
1303
  ),
1320
- originImg: import_koishi5.Schema.boolean().default(false).description("根据链接获取原图开关")
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>"')
1321
1306
  }).description("自用功能"),
1322
1307
  import_koishi5.Schema.union([
1323
1308
  import_koishi5.Schema.object({
@@ -1329,7 +1314,7 @@ var Config = import_koishi5.Schema.intersect([
1329
1314
  import_koishi5.Schema.object({
1330
1315
  featureControl: import_koishi5.Schema.array(
1331
1316
  import_koishi5.Schema.object({
1332
- functionName: import_koishi5.Schema.union(functionNames),
1317
+ functionName: import_koishi5.Schema.string(),
1333
1318
  whitelist: import_koishi5.Schema.boolean(),
1334
1319
  groups: import_koishi5.Schema.string()
1335
1320
  })
@@ -1342,7 +1327,7 @@ function apply(ctx, cfg) {
1342
1327
  baseDir = ctx.baseDir;
1343
1328
  assetsDir = `${ctx.baseDir}/data/starfx-bot/assets`;
1344
1329
  initAssets();
1345
- const featureControl = parseFeatureControl(cfg.featureControl);
1330
+ let featureControl = parseFeatureControl(cfg.featureControl);
1346
1331
  if (cfg.openLock) {
1347
1332
  ctx.command("封印 [param]").action(async ({ session }, param) => {
1348
1333
  if (ctx.QhzySharp && detectControl(featureControl, session.guildId, "lock"))
@@ -1663,6 +1648,229 @@ function apply(ctx, cfg) {
1663
1648
  }
1664
1649
  });
1665
1650
  }
1651
+ if (cfg.ktvServer && ctx.server) {
1652
+ let getHash = function(songs) {
1653
+ const content = songs.map((s) => `${s.id}|${s.title}|${s.url}`).join(",");
1654
+ return crypto.createHash("md5").update(content).digest("hex");
1655
+ }, songOperation = function(nowSongs, songIdArray, ops) {
1656
+ const latestSongMap = /* @__PURE__ */ new Map();
1657
+ if (Array.isArray(nowSongs)) {
1658
+ nowSongs.forEach((s) => s && s.id && latestSongMap.set(s.id, s));
1659
+ }
1660
+ [...ops].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)).forEach((op) => {
1661
+ if (op?.song?.id && op.toIndex !== -1) {
1662
+ latestSongMap.set(op.song.id, op.song);
1663
+ }
1664
+ });
1665
+ class ListNode {
1666
+ static {
1667
+ __name(this, "ListNode");
1668
+ }
1669
+ val;
1670
+ prev = null;
1671
+ next = null;
1672
+ constructor(val) {
1673
+ this.val = val;
1674
+ }
1675
+ }
1676
+ const head = new ListNode("HEAD");
1677
+ let current = head;
1678
+ const idNodes = /* @__PURE__ */ new Map();
1679
+ const anchorNodes = /* @__PURE__ */ new Map();
1680
+ for (let i = 0; i <= (songIdArray?.length || 0); i++) {
1681
+ const anchorNode = new ListNode(i);
1682
+ anchorNodes.set(i, anchorNode);
1683
+ current.next = anchorNode;
1684
+ anchorNode.prev = current;
1685
+ current = anchorNode;
1686
+ if (i < songIdArray.length) {
1687
+ const id = songIdArray[i];
1688
+ if (id !== void 0 && id !== null) {
1689
+ const idNode = new ListNode(id);
1690
+ idNodes.set(id, idNode);
1691
+ current.next = idNode;
1692
+ idNode.prev = current;
1693
+ current = idNode;
1694
+ }
1695
+ }
1696
+ }
1697
+ ops.forEach((op) => {
1698
+ if (!op?.song?.id) return;
1699
+ const { song, toIndex } = op;
1700
+ let node = idNodes.get(song.id);
1701
+ if (node && node.prev) {
1702
+ const prevNode = node.prev;
1703
+ const nextNode = node.next;
1704
+ prevNode.next = nextNode;
1705
+ if (nextNode) {
1706
+ nextNode.prev = prevNode;
1707
+ }
1708
+ node.prev = null;
1709
+ node.next = null;
1710
+ }
1711
+ if (toIndex === -1) {
1712
+ idNodes.delete(song.id);
1713
+ return;
1714
+ }
1715
+ if (!node) {
1716
+ node = new ListNode(song.id);
1717
+ idNodes.set(song.id, node);
1718
+ }
1719
+ const targetAnchor = anchorNodes.get(toIndex);
1720
+ if (targetAnchor && targetAnchor.prev) {
1721
+ const before = targetAnchor.prev;
1722
+ before.next = node;
1723
+ node.prev = before;
1724
+ node.next = targetAnchor;
1725
+ targetAnchor.prev = node;
1726
+ }
1727
+ });
1728
+ const result = [];
1729
+ let p = head.next;
1730
+ while (p !== null) {
1731
+ if (typeof p.val === "string" && p.val !== "HEAD") {
1732
+ const songData = latestSongMap.get(p.val);
1733
+ if (songData) {
1734
+ result.push(songData);
1735
+ }
1736
+ }
1737
+ p = p.next;
1738
+ }
1739
+ return result;
1740
+ };
1741
+ __name(getHash, "getHash");
1742
+ __name(songOperation, "songOperation");
1743
+ const templatePath = import_node_path4.default.resolve(assetsDir, "./songRoom.ejs");
1744
+ const templateStr = fs2.readFileSync(templatePath, "utf-8");
1745
+ const roomOpCache = {};
1746
+ const roomSongsCache = {};
1747
+ ctx.setInterval(() => {
1748
+ const now = Date.now();
1749
+ for (const roomId in roomOpCache) {
1750
+ roomOpCache[roomId] = roomOpCache[roomId].filter((log) => now - log.timestamp < 5 * 60 * 1e3);
1751
+ if (roomOpCache[roomId].length === 0) delete roomOpCache[roomId];
1752
+ }
1753
+ }, 5 * 60 * 1e3);
1754
+ ctx.server.get("/songRoom/api/:roomId", async (koaCtx) => {
1755
+ const { roomId } = koaCtx.params;
1756
+ const { lastHash: clientHash } = koaCtx.query;
1757
+ if (!roomSongsCache[roomId]?.length) {
1758
+ roomSongsCache[roomId] = await ctx.cache.get("ktv_room", roomId) || [];
1759
+ }
1760
+ const serverHash = getHash(roomSongsCache[roomId]);
1761
+ if (!roomOpCache[roomId]) {
1762
+ roomOpCache[roomId] = [{
1763
+ idArray: roomSongsCache[roomId].map((s) => s.id),
1764
+ // 顺序校验仍用 ID
1765
+ hash: serverHash,
1766
+ song: null,
1767
+ toIndex: -1,
1768
+ timestamp: Date.now()
1769
+ }];
1770
+ }
1771
+ if (clientHash === serverHash) {
1772
+ return koaCtx.body = { changed: false, hash: serverHash };
1773
+ }
1774
+ koaCtx.body = {
1775
+ changed: true,
1776
+ list: roomSongsCache[roomId],
1777
+ hash: serverHash
1778
+ };
1779
+ });
1780
+ ctx.server.post("/songRoom/api/:roomId", async (koaCtx) => {
1781
+ const { roomId } = koaCtx.params;
1782
+ const body = koaCtx.request["body"];
1783
+ const { idArrayHash, song, toIndex } = body;
1784
+ const logs = roomOpCache[roomId] || [];
1785
+ const hitIdx = logs.findIndex((l) => l.hash === idArrayHash);
1786
+ if (hitIdx === -1) return koaCtx.body = { success: false, code: "REJECT" };
1787
+ const baseLog = logs[hitIdx];
1788
+ const spotIds = [...baseLog.idArray];
1789
+ const nowSongs = roomSongsCache[roomId] || [];
1790
+ const currentOp = {
1791
+ idArray: [],
1792
+ // 此时 finalIdArray 还没算出,后面补上
1793
+ hash: "",
1794
+ song,
1795
+ toIndex,
1796
+ timestamp: Date.now()
1797
+ };
1798
+ const laterOps = [...logs.slice(hitIdx + 1), currentOp];
1799
+ const finalSongs = songOperation(nowSongs, spotIds, laterOps);
1800
+ const finalIds = finalSongs.map((s) => s.id);
1801
+ const finalHash = getHash(finalSongs);
1802
+ currentOp.idArray = finalIds;
1803
+ currentOp.hash = finalHash;
1804
+ logs.push(currentOp);
1805
+ roomSongsCache[roomId] = finalSongs;
1806
+ await ctx.cache.set(`ktv_room`, roomId, finalSongs);
1807
+ koaCtx.body = { success: true, hash: finalHash };
1808
+ });
1809
+ ctx.server.get("/songRoom/:roomId", async (koaCtx) => {
1810
+ const { roomId } = koaCtx.params;
1811
+ const html = import_ejs.default.render(templateStr, {
1812
+ roomId,
1813
+ pageTitle: `KTV 房间 - ${roomId}`
1814
+ });
1815
+ koaCtx.type = "html";
1816
+ koaCtx.body = html;
1817
+ });
1818
+ ctx.server.get("/songRoom", async (koaCtx) => {
1819
+ koaCtx.type = "html";
1820
+ koaCtx.body = `
1821
+ <!DOCTYPE html>
1822
+ <html>
1823
+ <head>
1824
+ <meta charset="UTF-8">
1825
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1826
+ <title>进入 KTV 房间</title>
1827
+ <script src="https://cdn.tailwindcss.com"></script>
1828
+ <style>
1829
+ @keyframes slideUp {
1830
+ from { opacity: 0; transform: translateY(20px); }
1831
+ to { opacity: 1; transform: translateY(0); }
1832
+ }
1833
+ .animate-pop { animation: slideUp 0.5s ease-out; }
1834
+ </style>
1835
+ </head>
1836
+ <body class="bg-slate-50 min-h-screen flex items-center justify-center p-6 text-slate-900">
1837
+ <div class="w-full max-w-sm bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 animate-pop">
1838
+ <header class="text-center mb-8">
1839
+ <h1 class="text-4xl font-black text-indigo-600 mb-2">KTV Queue</h1>
1840
+ <p class="text-slate-400 font-medium">输入房间号进入房间</p>
1841
+ </header>
1842
+
1843
+ <div class="space-y-4">
1844
+ <input id="roomInput" type="text" maxlength="10"
1845
+ 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"
1846
+ placeholder="0000" autofocus>
1847
+
1848
+ <button onclick="joinRoom()"
1849
+ 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">
1850
+ 进入房间
1851
+ </button>
1852
+ </div>
1853
+
1854
+ <p class="text-center text-slate-300 text-xs mt-8 uppercase tracking-widest font-bold">Powered by StarFreedomX</p>
1855
+ </div>
1856
+
1857
+ <script>
1858
+ function joinRoom() {
1859
+ const id = document.getElementById('roomInput').value.trim();
1860
+ if (id) {
1861
+ window.location.href = \`/songRoom/\${id}\`;
1862
+ }
1863
+ }
1864
+ // 支持回车键跳转
1865
+ document.getElementById('roomInput').addEventListener('keypress', (e) => {
1866
+ if (e.key === 'Enter') joinRoom();
1867
+ });
1868
+ </script>
1869
+ </body>
1870
+ </html>
1871
+ `;
1872
+ });
1873
+ }
1666
1874
  ctx.middleware(async (session, next) => {
1667
1875
  const elements = session.elements;
1668
1876
  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.25.0",
7
+ "version": "0.26.0",
8
8
  "main": "lib/index.js",
9
9
  "typings": "lib/index.d.ts",
10
10
  "files": [
@@ -48,12 +48,14 @@
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"
51
+ "rss-parser": "^3.13.0",
52
+ "ejs": "^3.1.10"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@biomejs/biome": "2.3.7",
55
56
  "@ltxhhz/koishi-plugin-skia-canvas": "^0.0.10",
56
57
  "@quanhuzeyu/koishi-plugin-qhzy-sharp": "^1.2.1",
57
- "@quanhuzeyu/sharp-for-koishi": "^0.0.7"
58
+ "@quanhuzeyu/sharp-for-koishi": "^0.0.7",
59
+ "@types/mime-types": "^3.0.1"
58
60
  }
59
61
  }
package/readme.md CHANGED
@@ -126,3 +126,4 @@ StarFreedomX机器人的小功能,自用
126
126
  | `0.24.1` | 检测sharp服务,删除无用配置 |
127
127
  | `0.24.2` | 修复检测逻辑 |
128
128
  | `0.25.0` | 优化功能控制模块 |
129
+ | `0.25.1` | 修正功能控制模块功能名相关bug |