koishi-plugin-starfx-bot 0.26.5 → 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 +862 -0
- package/lib/index.js +1 -2
- package/package.json +1 -1
|
@@ -0,0 +1,862 @@
|
|
|
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
|
+
animation: slideOutToRight 0.4s cubic-bezier(0.2, 0, 0, 1) forwards;
|
|
67
|
+
transition: all 0.4s cubic-bezier(0.2, 0, 0, 1);
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* 当进入删除状态时,高度坍塌 */
|
|
72
|
+
.slide-out-item[is-deleting="true"] {
|
|
73
|
+
max-height: 0 !important;
|
|
74
|
+
margin-bottom: 0 !important;
|
|
75
|
+
margin-top: 0 !important;
|
|
76
|
+
padding-top: 0 !important;
|
|
77
|
+
padding-bottom: 0 !important;
|
|
78
|
+
opacity: 0;
|
|
79
|
+
border-width: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.song-card:hover .font-bold {
|
|
83
|
+
color: #4f46e5; /* indigo-600 */
|
|
84
|
+
text-decoration: underline;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@keyframes slideOutToRight {
|
|
88
|
+
from { opacity: 1; transform: translateX(0); }
|
|
89
|
+
to { opacity: 0; transform: translateX(100px); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* 弹窗进入/离开的过渡 */
|
|
93
|
+
.modal-fade-enter-active,
|
|
94
|
+
.modal-fade-leave-active {
|
|
95
|
+
transition: all 0.3s ease;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* 初始/结束状态:遮罩透明度为0,内容缩小并稍微向下偏移 */
|
|
99
|
+
.modal-fade-enter-from,
|
|
100
|
+
.modal-fade-leave-to {
|
|
101
|
+
opacity: 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.modal-fade-enter-from .modal-container,
|
|
105
|
+
.modal-fade-leave-to .modal-container {
|
|
106
|
+
transform: scale(0.9) translateY(20px);
|
|
107
|
+
opacity: 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* 容器本身的过渡需要独立于遮罩,这样才有层次感 */
|
|
111
|
+
.modal-container {
|
|
112
|
+
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.ghost-card { opacity: 0.2; background: #6366f1 !important; }
|
|
116
|
+
.drag-handle { cursor: grab; }
|
|
117
|
+
</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body class="bg-slate-50 min-h-screen p-4 sm:p-8 text-slate-900">
|
|
120
|
+
<div id="app" class="max-w-md mx-auto" v-cloak>
|
|
121
|
+
<header class="mb-6 flex justify-between items-start">
|
|
122
|
+
<div>
|
|
123
|
+
<h1 class="text-3xl font-black text-indigo-600">KTV Queue</h1>
|
|
124
|
+
<p class="text-slate-400">房间: <%= roomId %></p>
|
|
125
|
+
</div>
|
|
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
|
+
</div>
|
|
137
|
+
</header>
|
|
138
|
+
|
|
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
|
+
添加新歌曲
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
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)"
|
|
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">
|
|
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
|
+
|
|
231
|
+
<transition name="modal-fade">
|
|
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">
|
|
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">
|
|
234
|
+
<h3 class="text-xl font-bold text-slate-800">编辑歌曲信息</h3>
|
|
235
|
+
|
|
236
|
+
<div class="space-y-3">
|
|
237
|
+
<div>
|
|
238
|
+
<label class="text-xs font-bold text-slate-400 ml-1 uppercase">歌曲名称</label>
|
|
239
|
+
<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="输入标题...">
|
|
240
|
+
</div>
|
|
241
|
+
<div>
|
|
242
|
+
<label class="text-xs font-bold text-slate-400 ml-1 uppercase">跳转链接</label>
|
|
243
|
+
<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://...">
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div class="flex space-x-3 pt-2">
|
|
248
|
+
<button @click="editingSong = null" class="flex-1 py-3 bg-slate-100 text-slate-600 font-bold rounded-xl hover:bg-slate-200 transition">
|
|
249
|
+
取消
|
|
250
|
+
</button>
|
|
251
|
+
<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">
|
|
252
|
+
保存修改
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</transition>
|
|
258
|
+
|
|
259
|
+
<transition name="modal-fade">
|
|
260
|
+
<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">
|
|
261
|
+
<div class="modal-container bg-white w-full max-w-sm rounded-3xl shadow-2xl border border-slate-100 p-6 space-y-6">
|
|
262
|
+
<div class="text-center">
|
|
263
|
+
<div class="w-16 h-16 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
264
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
265
|
+
<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>
|
|
266
|
+
</svg>
|
|
267
|
+
</div>
|
|
268
|
+
<h3 class="text-xl font-bold text-slate-800">确认删除?</h3>
|
|
269
|
+
<p class="text-slate-500 mt-2">歌曲 <span class="font-semibold text-slate-700">"{{ deletingSong.title }}"</span> 将被移除。</p>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="flex space-x-3">
|
|
272
|
+
<button @click="deletingSong = null" class="flex-1 py-3 bg-slate-100 text-slate-600 font-bold rounded-xl hover:bg-slate-200 transition">
|
|
273
|
+
返回
|
|
274
|
+
</button>
|
|
275
|
+
<button @click="confirmDelete" class="flex-1 py-3 bg-red-500 text-white font-bold rounded-xl hover:bg-red-600 transition">
|
|
276
|
+
确认移除
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</transition>
|
|
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
|
+
|
|
348
|
+
|
|
349
|
+
<div v-if="songs.length === 0" class="text-center py-20 text-slate-300">
|
|
350
|
+
列表是空的
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
<script>
|
|
355
|
+
const { createApp, ref, onMounted, onUnmounted } = Vue
|
|
356
|
+
createApp({
|
|
357
|
+
components: { draggable: window.vuedraggable },
|
|
358
|
+
setup() {
|
|
359
|
+
const roomId = "<%= roomId %>"
|
|
360
|
+
const EMPTY_HASH = "EMPTY_LIST_HASH"; // 与后端 getHash 函数中的占位符一致
|
|
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([]);
|
|
367
|
+
const form = ref({ title: '', url: '' })
|
|
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 将不可用,这可能会导致一些同步的问题");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
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
|
+
|
|
403
|
+
const commitOp = async (opData) => {
|
|
404
|
+
let cleanSong = null;
|
|
405
|
+
if (opData.song) {
|
|
406
|
+
const { id, title, url } = opData.song;
|
|
407
|
+
cleanSong = { id, title, url };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const res = await fetch(`${commitApiUrl}?roomId=${roomId}`, {
|
|
412
|
+
method: 'POST',
|
|
413
|
+
headers: { 'Content-Type': 'application/json' },
|
|
414
|
+
body: JSON.stringify({
|
|
415
|
+
idArrayHash: lastHash.value, // 这个 Hash 现在代表了旧列表的内容+顺序
|
|
416
|
+
toIndex: opData.toIndex,
|
|
417
|
+
song: cleanSong
|
|
418
|
+
})
|
|
419
|
+
}).then(r => r.json());
|
|
420
|
+
|
|
421
|
+
if (res.success) {
|
|
422
|
+
if (await getHash(songs.value) !== res.hash) await load();
|
|
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
|
+
}
|
|
431
|
+
return true;
|
|
432
|
+
} else if (res.code === 'REJECT') {
|
|
433
|
+
// 如果被拒绝,说明前端 Hash 过时或列表已空但前端不知道
|
|
434
|
+
lastHash.value = EMPTY_HASH; // 强制重置为初始状态
|
|
435
|
+
await load();
|
|
436
|
+
}
|
|
437
|
+
} catch (e) {
|
|
438
|
+
console.error("API Error:", e);
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
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
|
+
|
|
541
|
+
const add = async () => {
|
|
542
|
+
let rawUrl = form.value.url.trim();
|
|
543
|
+
if(!form.value.title || !rawUrl) return;
|
|
544
|
+
|
|
545
|
+
// 计算有效长度(排除正在删除的)
|
|
546
|
+
const effectiveLen = songs.value.filter(s => !s.isDeleting).length;
|
|
547
|
+
|
|
548
|
+
const newSong = {
|
|
549
|
+
id: 's-' + Math.random().toString(36).slice(2, 11),
|
|
550
|
+
title: form.value.title,
|
|
551
|
+
url: rawUrl,
|
|
552
|
+
isNew: true
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
songs.value.push(newSong);
|
|
556
|
+
form.value = { title: '', url: '' };
|
|
557
|
+
|
|
558
|
+
setTimeout(() => {
|
|
559
|
+
const target = songs.value.find(s => s.id === newSong.id);
|
|
560
|
+
if (target) target.isNew = false;
|
|
561
|
+
}, 600);
|
|
562
|
+
|
|
563
|
+
const success = await commitOp({
|
|
564
|
+
song: newSong,
|
|
565
|
+
toIndex: effectiveLen // 使用排除删除项后的索引
|
|
566
|
+
});
|
|
567
|
+
if (!success) await load();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 第一步:点击垃圾桶图标,仅记录要删除的对象并显示弹窗
|
|
571
|
+
const remove = (songObj) => {
|
|
572
|
+
deletingSong.value = songObj;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// 第二步:用户在弹窗点击“确认移除”
|
|
576
|
+
const confirmDelete = async () => {
|
|
577
|
+
const songObj = deletingSong.value;
|
|
578
|
+
if (!songObj) return;
|
|
579
|
+
|
|
580
|
+
// 先关闭弹窗,确保动画视觉焦点回到列表
|
|
581
|
+
deletingSong.value = null;
|
|
582
|
+
|
|
583
|
+
// 执行你原本的平滑删除逻辑
|
|
584
|
+
songObj.isDeleting = true;
|
|
585
|
+
|
|
586
|
+
setTimeout(async () => {
|
|
587
|
+
songs.value = songs.value.filter(s => s.id !== songObj.id);
|
|
588
|
+
await commitOp({
|
|
589
|
+
song: songObj,
|
|
590
|
+
toIndex: -1
|
|
591
|
+
});
|
|
592
|
+
}, 400); // 调整为 400ms 以匹配 CSS 坍塌速度
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// 获取最长递增子序列的索引下标
|
|
596
|
+
function getLISIndices(arr) {
|
|
597
|
+
const p = arr.slice();
|
|
598
|
+
const result = [0];
|
|
599
|
+
let i, j, u, v, c;
|
|
600
|
+
const len = arr.length;
|
|
601
|
+
for (i = 0; i < len; i++) {
|
|
602
|
+
const arrI = arr[i];
|
|
603
|
+
if (arrI !== -1) {
|
|
604
|
+
j = result[result.length - 1];
|
|
605
|
+
if (arr[j] < arrI) {
|
|
606
|
+
p[i] = j;
|
|
607
|
+
result.push(i);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
u = 0;
|
|
611
|
+
v = result.length - 1;
|
|
612
|
+
while (u < v) {
|
|
613
|
+
c = (u + v) >> 1;
|
|
614
|
+
if (arr[result[c]] < arrI) u = c + 1;
|
|
615
|
+
else v = c;
|
|
616
|
+
}
|
|
617
|
+
if (arrI < arr[result[u]]) {
|
|
618
|
+
if (u > 0) p[i] = result[u - 1];
|
|
619
|
+
result[u] = i;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
u = result.length;
|
|
624
|
+
v = result[u - 1];
|
|
625
|
+
while (u-- > 0) {
|
|
626
|
+
result[u] = v;
|
|
627
|
+
v = p[v];
|
|
628
|
+
}
|
|
629
|
+
return result;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const load = async () => {
|
|
633
|
+
if (isDragging.value) return;
|
|
634
|
+
try {
|
|
635
|
+
const url = `${loadSongListUrl}?roomId=${roomId}&lastHash=${(await getHash(songs.value))}`;
|
|
636
|
+
const res = await fetch(url).then(r => r.json());
|
|
637
|
+
|
|
638
|
+
if (res.changed) {
|
|
639
|
+
const oldSongs = [...songs.value];
|
|
640
|
+
const newSongsData = res.list || []; // 处理空返回
|
|
641
|
+
|
|
642
|
+
// 如果新数据就是空的,直接赋值并更新 Hash,跳过后续复杂的 LIS 计算
|
|
643
|
+
if (newSongsData.length === 0) {
|
|
644
|
+
songs.value = [];
|
|
645
|
+
lastHash.value = res.hash || EMPTY_HASH;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 计算 ID 映射
|
|
650
|
+
const oldIdMap = new Map();
|
|
651
|
+
oldSongs.forEach((s, i) => { if (!s.isDeleting) oldIdMap.set(s.id, i); });
|
|
652
|
+
|
|
653
|
+
// 识别“主动移动”的 ID
|
|
654
|
+
const source = newSongsData.map(s => oldIdMap.has(s.id) ? oldIdMap.get(s.id) : -1);
|
|
655
|
+
const lisIndices = new Set(getLISIndices(source));
|
|
656
|
+
|
|
657
|
+
const activeMoveIds = new Set();
|
|
658
|
+
newSongsData.forEach((s, newIdx) => {
|
|
659
|
+
const oldIdx = oldIdMap.get(s.id);
|
|
660
|
+
// 只有既不在 LIS 里、又不是真正的新歌,才是我们要处理的“改动元素”
|
|
661
|
+
if (oldIdx !== undefined && oldIdx !== newIdx && !lisIndices.has(newIdx)) {
|
|
662
|
+
activeMoveIds.add(s.id);
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// 让删除项和“改动项”一起执行退出动画
|
|
667
|
+
const newIdSet = new Set(newSongsData.map(s => s.id));
|
|
668
|
+
oldSongs.forEach(s => {
|
|
669
|
+
// 如果是服务器删了,或者它是主动移动项,执行退出
|
|
670
|
+
if (!newIdSet.has(s.id) || activeMoveIds.has(s.id)) {
|
|
671
|
+
s.isDeleting = true;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// 等待退出动画完成
|
|
676
|
+
setTimeout(() => {
|
|
677
|
+
// 构建最终列表
|
|
678
|
+
songs.value = newSongsData.map((s, newIdx) => {
|
|
679
|
+
const oldIdx = oldIdMap.get(s.id);
|
|
680
|
+
const isNew = oldIdx === undefined;
|
|
681
|
+
const isActiveMove = activeMoveIds.has(s.id);
|
|
682
|
+
|
|
683
|
+
// 被动移动的判定(在 LIS 里但位置变了)
|
|
684
|
+
const isAffected = !isNew && !isActiveMove && oldIdx !== newIdx;
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
...s,
|
|
688
|
+
// 入场动画
|
|
689
|
+
isMoved: isActiveMove,
|
|
690
|
+
isNew: (isNew || isActiveMove), //&& oldSongs.length > 0,
|
|
691
|
+
isAffected: isAffected
|
|
692
|
+
};
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
lastHash.value = res.hash;
|
|
696
|
+
|
|
697
|
+
// 清理状态
|
|
698
|
+
setTimeout(() => {
|
|
699
|
+
songs.value.forEach(s => {
|
|
700
|
+
s.isNew = s.isAffected = false;
|
|
701
|
+
});
|
|
702
|
+
}, 600);
|
|
703
|
+
}, 350);
|
|
704
|
+
}
|
|
705
|
+
} catch (e) { console.error("Load Error:", e); }
|
|
706
|
+
};
|
|
707
|
+
|
|
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;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const onDragChange = async (evt) => {
|
|
749
|
+
isDragging.value = false;
|
|
750
|
+
if (evt.moved) {
|
|
751
|
+
const { element, newIndex } = evt.moved;
|
|
752
|
+
// 修正向后移动时的标尺偏移
|
|
753
|
+
// let targetK = newIndex;
|
|
754
|
+
/* if (newIndex > oldIndex) {
|
|
755
|
+
targetK = newIndex + 1;
|
|
756
|
+
}*/
|
|
757
|
+
|
|
758
|
+
await commitOp({
|
|
759
|
+
song: element,
|
|
760
|
+
toIndex: newIndex // 发送修正后的标尺
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// 点击编辑按钮触发
|
|
766
|
+
const startEdit = (song) => {
|
|
767
|
+
editingSong.value = song;
|
|
768
|
+
// 深拷贝一份数据给表单,防止未保存就直接修改了列表
|
|
769
|
+
editForm.value = { title: song.title, url: song.url };
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const moveToTop = async (song) => {
|
|
773
|
+
// 如果已经在第一位,无需操作
|
|
774
|
+
if (songs.value[0]?.id === song.id) return;
|
|
775
|
+
|
|
776
|
+
// 这里的逻辑与 load 中的“主动移动”一致
|
|
777
|
+
const oldIndex = songs.value.findIndex(s => s.id === song.id);
|
|
778
|
+
if (oldIndex !== -1) {
|
|
779
|
+
// 标记为正在移动
|
|
780
|
+
song.isDeleting = true;
|
|
781
|
+
|
|
782
|
+
setTimeout(async () => {
|
|
783
|
+
// 从原位置移除
|
|
784
|
+
const [movedItem] = songs.value.splice(oldIndex, 1);
|
|
785
|
+
// 插入到最前面
|
|
786
|
+
songs.value.unshift(movedItem);
|
|
787
|
+
|
|
788
|
+
// 重置状态并触发高亮
|
|
789
|
+
movedItem.isDeleting = false;
|
|
790
|
+
movedItem.isTop = true;
|
|
791
|
+
movedItem.isNew = true;
|
|
792
|
+
|
|
793
|
+
// 发送给后端,toIndex: 目标顺位
|
|
794
|
+
const success = await commitOp({
|
|
795
|
+
song: movedItem,
|
|
796
|
+
toIndex: 0
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
if (!success) {
|
|
800
|
+
await load();
|
|
801
|
+
} else {
|
|
802
|
+
// 成功后延迟移除高亮
|
|
803
|
+
setTimeout(() => { movedItem.isTop = false; movedItem.isNew = false; }, 1000);
|
|
804
|
+
}
|
|
805
|
+
}, 300);
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
// 保存逻辑
|
|
810
|
+
const saveEdit = async () => {
|
|
811
|
+
if (!editForm.value.title || !editForm.value.url) return;
|
|
812
|
+
|
|
813
|
+
const song = editingSong.value;
|
|
814
|
+
const index = songs.value.findIndex(s => s.id === song.id);
|
|
815
|
+
|
|
816
|
+
if (index !== -1) {
|
|
817
|
+
const oldData = { title: song.title, url: song.url };
|
|
818
|
+
|
|
819
|
+
// 乐观更新 UI
|
|
820
|
+
song.title = editForm.value.title;
|
|
821
|
+
song.url = editForm.value.url;
|
|
822
|
+
|
|
823
|
+
const success = await commitOp({
|
|
824
|
+
song: song,
|
|
825
|
+
toIndex: index // 原位覆盖更新
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
if (success) {
|
|
829
|
+
editingSong.value = null;
|
|
830
|
+
} else {
|
|
831
|
+
// 失败回退
|
|
832
|
+
song.title = oldData.title;
|
|
833
|
+
song.url = oldData.url;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
let timer
|
|
843
|
+
onMounted(() => {
|
|
844
|
+
load()
|
|
845
|
+
// 每 3 秒同步一次数据
|
|
846
|
+
timer = setInterval(load, 5000)
|
|
847
|
+
})
|
|
848
|
+
onUnmounted(() => clearInterval(timer))
|
|
849
|
+
|
|
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
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}).mount('#app')
|
|
860
|
+
</script>
|
|
861
|
+
</body>
|
|
862
|
+
</html>
|
package/lib/index.js
CHANGED
|
@@ -738,7 +738,6 @@ function handleRoll(session) {
|
|
|
738
738
|
parts.push(element);
|
|
739
739
|
}
|
|
740
740
|
}
|
|
741
|
-
console.log(parts);
|
|
742
741
|
parts.shift();
|
|
743
742
|
if (!parts) return session.text(".noParam");
|
|
744
743
|
const last = session.elements[session.elements.length - 1];
|
|
@@ -770,7 +769,7 @@ function handleRoll(session) {
|
|
|
770
769
|
}
|
|
771
770
|
__name(handleRoll, "handleRoll");
|
|
772
771
|
function getPoints(session, num, noodles) {
|
|
773
|
-
if (!Number.isInteger(num) || !Number.isInteger(noodles) || num
|
|
772
|
+
if (!Number.isInteger(num) || !Number.isInteger(noodles) || num <= 0 || noodles <= 0)
|
|
774
773
|
return session.text(".invalid");
|
|
775
774
|
if (num > 20 || noodles > 1e8) return session.text(".too-many");
|
|
776
775
|
const points = Array(num).fill(0).map(() => Math.floor(Math.random() * noodles + 1));
|