仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/components/CustomSourceImport.vue 9.9 KB · 408 行 原始文件
1<script setup>
2/**
3 * CustomSourceImport — 导入自定义音源
4 *
5 * 三种方式(与移动端一致):
6 * 1. 粘贴脚本源码
7 * 2. 选择本地 .js 文件(PC 端走原生文件对话框)
8 * 3. 从网络地址抓取
9 * 导入成功后刷新源列表并广播,由调用方决定是否立即启用。
10 */
11import { ref, computed } from 'vue'
12import Icon from './Icon.vue'
13import UiDialog from './UiDialog.vue'
14import { addUserApi } from '../common/data.js'
15import { updateUserApiList } from '../common/user-api/api-executor.js'
16import { pickTextFile, PICK_CANCELED } from '../common/file-picker.js'
17import { toastOk, toastErr, errText } from '../composables/useFeedback.js'
18import { openExternal } from '../uni/native.js'
19
20const props = defineProps({
21 visible: { type: Boolean, default: false },
22})
23const emit = defineEmits(['update:visible', 'imported'])
24
25const TAB_PASTE = 'paste'
26const TAB_FILE = 'file'
27const TAB_URL = 'url'
28
29const tab = ref(TAB_PASTE)
30const scriptText = ref('')
31const urlText = ref('')
32const fileName = ref('')
33const loading = ref(false)
34const error = ref('')
35
36const tabs = [
37 { key: TAB_PASTE, label: '粘贴脚本', icon: 'edit' },
38 { key: TAB_FILE, label: '本地文件', icon: 'folder' },
39 { key: TAB_URL, label: '网络地址', icon: 'link' },
40]
41
42const canSubmit = computed(() => {
43 if (tab.value === TAB_PASTE) return !!scriptText.value.trim()
44 if (tab.value === TAB_FILE) return !!scriptText.value.trim()
45 return !!urlText.value.trim()
46})
47
48const reset = () => {
49 scriptText.value = ''
50 urlText.value = ''
51 fileName.value = ''
52 error.value = ''
53 loading.value = false
54}
55
56const doImport = (script) => {
57 try {
58 const info = addUserApi(script)
59 updateUserApiList()
60 toastOk(`导入成功:${info.name}`)
61 emit('imported', info)
62 emit('update:visible', false)
63 reset()
64 } catch (err) {
65 error.value = errText(err, '导入失败')
66 toastErr(error.value)
67 }
68}
69
70const submitPaste = () => {
71 const script = scriptText.value.trim()
72 if (!script) {
73 error.value = '请粘贴音源脚本内容'
74 return
75 }
76 doImport(script)
77}
78
79const submitFile = async () => {
80 error.value = ''
81 try {
82 const file = await pickTextFile('.js')
83 if (!file || !file.content) return
84 fileName.value = file.name
85 scriptText.value = file.content
86 tab.value = TAB_PASTE
87 toastOk(`已读取 ${file.name}`)
88 } catch (err) {
89 if (String(err.message) === PICK_CANCELED) return
90 error.value = errText(err, '读取文件失败')
91 toastErr(error.value)
92 }
93}
94
95const submitUrl = async () => {
96 const url = urlText.value.trim()
97 if (!/^https?:\/\//i.test(url)) {
98 error.value = '请输入有效的 http/https 地址'
99 return
100 }
101 error.value = ''
102 loading.value = true
103 uni.request({
104 url,
105 method: 'GET',
106 timeout: 20000,
107 success: (res) => {
108 const text = typeof res.data === 'string' ? res.data : ''
109 if (!text || text.length < 10) {
110 error.value = '地址返回内容为空或格式不正确,可尝试用浏览器打开后手动粘贴'
111 return
112 }
113 doImport(text)
114 },
115 fail: (err) => {
116 error.value = `获取脚本失败:${(err && err.errMsg) || '网络异常'}`
117 },
118 complete: () => {
119 loading.value = false
120 },
121 })
122}
123
124const submit = () => {
125 if (loading.value) return
126 if (tab.value === TAB_URL) submitUrl()
127 else if (tab.value === TAB_FILE) submitFile()
128 else submitPaste()
129}
130
131const openHomepage = () => {
132 openExternal('https://lxmusic.toside.cn/')
133}
134</script>
135
136<template>
137 <UiDialog
138 :visible="visible"
139 title="导入自定义音源"
140 subtitle="兼容 LX Music 源脚本协议,导入后即可解析播放地址、歌词与封面"
141 :width="620"
142 :before-close="() => !loading"
143 @update:visible="emit('update:visible', $event)"
144 @close="reset"
145 >
146 <div class="tabs">
147 <button
148 v-for="t in tabs"
149 :key="t.key"
150 class="tab"
151 :class="{ 'is-active': tab === t.key }"
152 @click="tab = t.key; error = ''"
153 >
154 <Icon :name="t.icon" :size="14" />
155 <span>{{ t.label }}</span>
156 </button>
157 </div>
158
159 <!-- 粘贴 / 文件读取结果 -->
160 <template v-if="tab === TAB_PASTE">
161 <textarea
162 v-model="scriptText"
163 class="ta"
164 rows="10"
165 spellcheck="false"
166 placeholder="/*&#10; * @name 我的音源&#10; * @description 说明&#10; * @version 1.0.0&#10; * @author 作者&#10; */&#10;&#10;const { EVENT_NAMES, request, on, send } = globalThis.lx&#10;..."
167 />
168 <div class="hint-row">
169 <span class="hint">
170 <Icon name="info" :size="13" />
171 脚本头部需包含 <code>@name</code> 等注释信息
172 </span>
173 <button class="text-btn" @click="scriptText = ''">清空</button>
174 </div>
175 </template>
176
177 <template v-else-if="tab === TAB_FILE">
178 <div class="drop" @click="submitFile">
179 <span class="drop-icon"><Icon name="upload" :size="26" /></span>
180 <p class="drop-title">点击选择音源脚本文件</p>
181 <p class="drop-sub">支持 .js / .txt,PC 端直接调用系统文件对话框</p>
182 <p v-if="fileName" class="drop-file">
183 <Icon name="check" :size="13" />
184 已读取:{{ fileName }}
185 </p>
186 </div>
187 </template>
188
189 <template v-else>
190 <input v-model="urlText" class="ipt" type="text" spellcheck="false" placeholder="https://example.com/music-source.js" />
191 <div class="hint-row">
192 <span class="hint">
193 <Icon name="shield" :size="13" />
194 仅从可信来源导入,脚本会在本地沙箱中执行
195 </span>
196 <button class="text-btn" @click="openHomepage">
197 查看协议说明
198 <Icon name="external" :size="12" />
199 </button>
200 </div>
201 </template>
202
203 <p v-if="error" class="err">{{ error }}</p>
204
205 <template #footer>
206 <button class="btn press" @click="emit('update:visible', false)">取消</button>
207 <button class="btn is-primary press" :disabled="loading || (!canSubmit && tab !== TAB_FILE)" @click="submit">
208 <Icon v-if="loading" name="refresh" :size="14" spin />
209 <span>{{ loading ? '处理中…' : tab === TAB_FILE ? '选择文件' : '导入' }}</span>
210 </button>
211 </template>
212 </UiDialog>
213</template>
214
215<style scoped>
216.tabs {
217 display: flex;
218 gap: 6px;
219 margin-bottom: 14px;
220 padding: 4px;
221 border-radius: var(--r-md);
222 background: var(--surface-3);
223}
224
225.tab {
226 flex: 1;
227 display: inline-flex;
228 align-items: center;
229 justify-content: center;
230 gap: 6px;
231 height: 32px;
232 border-radius: var(--r-sm);
233 font-size: 12.5px;
234 color: var(--text-3);
235 transition: background-color var(--t-base) var(--ease-out), color var(--t-base) var(--ease-out),
236 box-shadow var(--t-base) var(--ease-out);
237}
238
239.tab:hover {
240 color: var(--text-1);
241}
242
243.tab.is-active {
244 background: var(--surface-1);
245 color: var(--theme-color-dark);
246 font-weight: 600;
247 box-shadow: var(--sh-xs);
248}
249
250.ta,
251.ipt {
252 width: 100%;
253 padding: 11px 13px;
254 border-radius: var(--r-md);
255 border: 1.5px solid var(--line-2);
256 background: var(--surface-2);
257 color: var(--text-1);
258 outline: none;
259 resize: vertical;
260 transition: border-color var(--t-base) var(--ease-out), box-shadow var(--t-base) var(--ease-out);
261}
262
263.ta {
264 font-family: var(--font-mono);
265 font-size: 12px;
266 line-height: 1.65;
267}
268
269.ta:focus,
270.ipt:focus {
271 border-color: var(--theme-color);
272 box-shadow: 0 0 0 4px rgba(var(--theme-color-rgb), 0.12);
273 background: var(--surface-1);
274}
275
276.hint-row {
277 display: flex;
278 align-items: center;
279 justify-content: space-between;
280 gap: 10px;
281 margin-top: 9px;
282}
283
284.hint {
285 display: inline-flex;
286 align-items: center;
287 gap: 6px;
288 font-size: 11.5px;
289 color: var(--text-4);
290}
291
292.hint code {
293 padding: 0 4px;
294 border-radius: 4px;
295 background: var(--surface-3);
296 font-family: var(--font-mono);
297 font-size: 11px;
298 color: var(--theme-color-dark);
299}
300
301.text-btn {
302 display: inline-flex;
303 align-items: center;
304 gap: 4px;
305 font-size: 11.5px;
306 color: var(--text-4);
307 white-space: nowrap;
308 transition: color var(--t-fast) var(--ease-out);
309}
310
311.text-btn:hover {
312 color: var(--theme-color);
313}
314
315/* 文件拖放区 */
316.drop {
317 display: flex;
318 flex-direction: column;
319 align-items: center;
320 gap: 8px;
321 padding: 34px 20px;
322 border-radius: var(--r-lg);
323 border: 1.5px dashed var(--line-3);
324 background: var(--surface-2);
325 cursor: pointer;
326 transition: border-color var(--t-base) var(--ease-out), background-color var(--t-base) var(--ease-out);
327}
328
329.drop:hover {
330 border-color: var(--theme-color);
331 background: var(--theme-color-soft);
332}
333
334.drop-icon {
335 display: grid;
336 place-items: center;
337 width: 56px;
338 height: 56px;
339 border-radius: var(--r-full);
340 color: var(--theme-color);
341 background: var(--surface-1);
342 box-shadow: var(--sh-sm);
343}
344
345.drop-title {
346 font-size: 14px;
347 font-weight: 600;
348 color: var(--text-1);
349}
350
351.drop-sub {
352 font-size: 11.5px;
353 color: var(--text-4);
354}
355
356.drop-file {
357 display: inline-flex;
358 align-items: center;
359 gap: 6px;
360 margin-top: 4px;
361 font-size: 12px;
362 color: var(--color-success);
363}
364
365.err {
366 margin-top: 10px;
367 font-size: 12px;
368 color: var(--color-danger);
369}
370
371.btn {
372 display: inline-flex;
373 align-items: center;
374 gap: 6px;
375 height: 34px;
376 padding: 0 16px;
377 border-radius: var(--r-sm);
378 border: 1px solid var(--line-2);
379 background: var(--surface-1);
380 color: var(--text-2);
381 font-size: 13px;
382 transition: border-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
383 background-color var(--t-fast) var(--ease-out);
384}
385
386.btn:hover:not(:disabled) {
387 border-color: var(--theme-color);
388 color: var(--theme-color-dark);
389 background: var(--theme-color-soft);
390}
391
392.btn:disabled {
393 opacity: 0.45;
394 cursor: not-allowed;
395}
396
397.btn.is-primary {
398 color: #fff;
399 border-color: transparent;
400 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
401 box-shadow: 0 4px 14px rgba(var(--theme-color-rgb), 0.3);
402}
403
404.btn.is-primary:hover:not(:disabled) {
405 filter: brightness(1.06);
406 color: #fff;
407}
408</style>