podflow 20250429__py3-none-any.whl → 20250522__py3-none-any.whl

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,832 @@
1
+ // /templates/js/config.js
2
+
3
+ document.addEventListener('DOMContentLoaded', () => {
4
+ const configContainer = document.getElementById('configContainer');
5
+ const refreshConfigBtn = document.getElementById('refreshConfigBtn');
6
+ const saveConfigBtn = document.getElementById('saveConfigBtn');
7
+ const configStatus = document.getElementById('configStatus');
8
+ const configPage = document.getElementById('pageConfig');
9
+
10
+ let currentConfig = null; // 用于存储当前加载的配置
11
+
12
+ // --- Helper Function: 创建表单元素 ---
13
+ function createInputElement(key, value, path) {
14
+ const itemDiv = document.createElement('div');
15
+ itemDiv.classList.add('config-item');
16
+
17
+ const label = document.createElement('label');
18
+ label.htmlFor = `config-${path}`;
19
+ label.textContent = `${key}:`;
20
+ itemDiv.appendChild(label);
21
+
22
+ let input;
23
+ const inputId = `config-${path}`;
24
+
25
+ if (typeof value === 'boolean') {
26
+ input = document.createElement('input');
27
+ input.type = 'checkbox';
28
+ input.checked = value;
29
+ input.id = inputId;
30
+ input.dataset.path = path; // 存储路径用于保存
31
+ input.dataset.type = 'boolean';
32
+ // Checkbox 需要特殊布局,将其放在label后面
33
+ label.style.display = 'inline-block'; // 让label和checkbox在同一行
34
+ input.style.marginLeft = '10px';
35
+ itemDiv.appendChild(input);
36
+ } else if (typeof value === 'number') {
37
+ input = document.createElement('input');
38
+ input.type = 'number';
39
+ input.value = value;
40
+ input.id = inputId;
41
+ input.dataset.path = path;
42
+ input.dataset.type = 'number';
43
+ // 对于整数,设置 step="1"
44
+ if (Number.isInteger(value)) {
45
+ input.step = "1";
46
+ } else {
47
+ input.step = "any"; // 允许小数
48
+ }
49
+ itemDiv.appendChild(document.createElement('br')); // 换行
50
+ itemDiv.appendChild(input);
51
+ } else if (key === 'media' && path.includes('channelid_')) { // media 类型特殊处理为下拉框
52
+ input = document.createElement('select');
53
+ input.id = inputId;
54
+ input.dataset.path = path;
55
+ input.dataset.type = 'string'; // 本质是字符串
56
+ const options = ['m4a', 'mp4', 'webm']; // 可能的媒体类型
57
+ options.forEach(opt => {
58
+ const option = document.createElement('option');
59
+ option.value = opt;
60
+ option.textContent = opt;
61
+ if (opt === value) {
62
+ option.selected = true;
63
+ }
64
+ input.appendChild(option);
65
+ });
66
+ itemDiv.appendChild(document.createElement('br'));
67
+ itemDiv.appendChild(input);
68
+ }
69
+ // mode 字段现在可能出现在嵌套的 title_change 中,路径会包含 title_change
70
+ else if (key === 'mode' && path.includes('.title_change[')) { // title_change 数组项内部的 mode 特殊处理为下拉框
71
+ input = document.createElement('select');
72
+ input.id = inputId;
73
+ input.dataset.path = path;
74
+ input.dataset.type = 'string';
75
+ const options = ['add-left', 'add-right', 'replace'];
76
+ options.forEach(opt => {
77
+ const option = document.createElement('option');
78
+ option.value = opt;
79
+ option.textContent = opt;
80
+ if (opt === value) {
81
+ option.selected = true;
82
+ }
83
+ input.appendChild(option);
84
+ });
85
+ itemDiv.appendChild(document.createElement('br'));
86
+ itemDiv.appendChild(input);
87
+ }
88
+ else { // 默认为文本输入
89
+ input = document.createElement('input');
90
+ input.type = 'text'; // 或 'url' 如果需要验证
91
+ if (key === 'link' || key === 'icon' || (key === 'url' && !path.includes('.title_change['))) {
92
+ input.type = 'url';
93
+ }
94
+ input.value = value;
95
+ input.id = inputId;
96
+ input.dataset.path = path;
97
+ input.dataset.type = 'string';
98
+ itemDiv.appendChild(document.createElement('br'));
99
+ itemDiv.appendChild(input);
100
+ }
101
+
102
+ // 为基本的文本/数字/URL输入添加类方便样式控制
103
+ if (input.tagName === 'INPUT' && (input.type === 'text' || input.type === 'number' || input.type === 'url')) {
104
+ input.classList.add('config-input-text');
105
+ } else if (input.tagName === 'SELECT') {
106
+ input.classList.add('config-input-select');
107
+ }
108
+
109
+
110
+ return itemDiv;
111
+ }
112
+
113
+ // --- Helper Function: 创建按钮 ---
114
+ function createButton(text, className, onClick) {
115
+ const button = document.createElement('button');
116
+ button.textContent = text;
117
+ button.classList.add('config-button', className);
118
+ button.type = 'button'; // 防止触发表单提交
119
+ button.addEventListener('click', onClick);
120
+ return button;
121
+ }
122
+
123
+ // --- Helper Function: 生成唯一临时 key ---
124
+ function generateTempKey(prefix = 'temp_') {
125
+ // 使用当前时间戳和随机字符串生成一个足够唯一的临时 key
126
+ return `${prefix}${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
127
+ }
128
+
129
+
130
+ // --- Helper Function: 递归渲染配置 ---
131
+ function renderConfig(data, parentElement, currentPath = '') {
132
+ // 清空当前容器,准备重新渲染
133
+ parentElement.innerHTML = '';
134
+
135
+ Object.entries(data).forEach(([key, value]) => {
136
+ const path = currentPath ? `${currentPath}.${key}` : key;
137
+
138
+ if (key === 'channelid_youtube' || key === 'channelid_bilibili') {
139
+ const sectionDiv = document.createElement('div');
140
+ sectionDiv.classList.add('collapsible-section');
141
+ sectionDiv.dataset.path = path;
142
+ sectionDiv.dataset.type = 'object';
143
+
144
+ const header = document.createElement('div');
145
+ header.classList.add('collapsible-header');
146
+ header.innerHTML = `<span class="section-title">${key}:</span> <span class="toggle-icon">▶️</span>`; // 初始图标
147
+
148
+ // ** 添加“添加频道”按钮 **
149
+ const addChannelBtn = createButton('添加', 'add-button', (e) => {
150
+ e.stopPropagation(); // 阻止点击按钮时触发折叠/展开
151
+ addChannel(path);
152
+ });
153
+ header.appendChild(addChannelBtn);
154
+
155
+
156
+ header.addEventListener('click', () => {
157
+ const content = sectionDiv.querySelector('.collapsible-content');
158
+ const icon = header.querySelector('.toggle-icon');
159
+ const isHidden = content.classList.toggle('hidden');
160
+ header.classList.toggle('expanded', !isHidden); // 添加/删除 expanded 类
161
+ icon.textContent = isHidden ? '▶️' : '🔽'; // 切换图标
162
+ });
163
+ sectionDiv.appendChild(header);
164
+
165
+ const contentDiv = document.createElement('div');
166
+ contentDiv.classList.add('collapsible-content', 'hidden'); // 默认隐藏
167
+
168
+ // 确保 value 是一个对象,即使为空也要显示添加按钮
169
+ if (value && typeof value === 'object') {
170
+ const channelKeys = Object.keys(value);
171
+ if (channelKeys.length > 0) {
172
+ channelKeys.forEach((channelKey) => {
173
+ const channelConfig = value[channelKey];
174
+ // 使用一个稳定的 key 用于 DOM 元素的 data-path。
175
+ // 当从后端加载时,使用真实的 channelKey;
176
+ // 当添加新频道时,使用临时 key。
177
+ // 在 collectConfigData 时再根据 input[name="id"] 的 value 来构建最终 JSON。
178
+ const domKey = channelConfig._tempKey || channelKey; // 优先使用临时 key 如果存在
179
+ const channelPath = `${path}.${domKey}`;
180
+
181
+
182
+ const channelSectionDiv = document.createElement('div');
183
+ channelSectionDiv.classList.add('collapsible-section', 'channel-item'); // 添加 channel-item 类方便识别
184
+ channelSectionDiv.dataset.path = channelPath; // 使用 DOM key 作为 data-path
185
+ channelSectionDiv.dataset.type = 'object';
186
+
187
+
188
+ const channelHeader = document.createElement('div');
189
+ channelHeader.classList.add('collapsible-header');
190
+
191
+ // 获取用户输入的 ID 值,如果存在,用于显示
192
+ const currentIdValue = channelConfig?.id ?? domKey; // 优先显示 id 值,否则显示 domKey
193
+
194
+ channelHeader.innerHTML = `
195
+ <span>
196
+ <span class="channel-display-key">${currentIdValue}</span>
197
+ </span>
198
+ <span class="toggle-icon">▶️</span>`;
199
+
200
+ // ** 添加“删除频道”按钮 **
201
+ const deleteChannelBtn = createButton('删除', 'delete-button', (e) => {
202
+ e.stopPropagation(); // 阻止点击按钮时触发折叠/展开
203
+ deleteChannel(path, channelKey); // 删除时使用真实的 channelKey
204
+ });
205
+ channelHeader.appendChild(deleteChannelBtn);
206
+
207
+
208
+ channelHeader.addEventListener('click', (e) => {
209
+ e.stopPropagation(); // 防止点击内部 Header 时触发外部 Header 的事件
210
+ const channelContent = channelSectionDiv.querySelector('.collapsible-content');
211
+ const icon = channelHeader.querySelector('.toggle-icon');
212
+ const isHidden = channelContent.classList.toggle('hidden');
213
+ header.classList.toggle('expanded', !isHidden); // 切换外部 header 的 expanded 状态
214
+ channelHeader.classList.toggle('expanded', !isHidden); // 切换内部 header 的 expanded 状态
215
+ icon.textContent = isHidden ? '▶️' : '🔽';
216
+ });
217
+ channelSectionDiv.appendChild(channelHeader);
218
+
219
+ const channelContentDiv = document.createElement('div');
220
+ channelContentDiv.classList.add('collapsible-content', 'hidden'); // 默认隐藏详细配置
221
+
222
+ // 递归渲染频道内部配置
223
+ // 创建一个临时对象用于渲染,不包含 _tempKey
224
+ const channelConfigForRender = { ...channelConfig };
225
+ delete channelConfigForRender._tempKey;
226
+ renderConfig(channelConfigForRender, channelContentDiv, channelPath);
227
+
228
+ // ** 在频道内部添加 title_change 渲染逻辑 **
229
+ const titleChangeArray = channelConfigForRender.title_change;
230
+ if (Array.isArray(titleChangeArray)) {
231
+ const titleChangePath = `${channelPath}.title_change`; // 新的 title_change 路径
232
+ const titleChangeListDiv = document.createElement('div');
233
+ titleChangeListDiv.classList.add('title-change-list');
234
+ titleChangeListDiv.dataset.path = titleChangePath;
235
+ titleChangeListDiv.dataset.type = 'array';
236
+
237
+ const titleChangeLabel = document.createElement('label');
238
+ titleChangeLabel.textContent = `标题修改规则:`;
239
+ titleChangeListDiv.appendChild(titleChangeLabel);
240
+
241
+ // ** 添加频道内部的“添加规则”按钮 **
242
+ const addRuleBtn = createButton('添加规则', 'add-button', (e) => {
243
+ e.stopPropagation();
244
+ addTitleChangeRule(titleChangePath); // 传递频道内部的 title_change 路径
245
+ });
246
+ titleChangeListDiv.appendChild(addRuleBtn);
247
+
248
+
249
+ if (titleChangeArray.length > 0) {
250
+ titleChangeArray.forEach((item, index) => {
251
+ const itemPath = `${titleChangePath}[${index}]`; // 数组项的路径
252
+ const itemDiv = document.createElement('div');
253
+ itemDiv.classList.add('title-change-item');
254
+ itemDiv.dataset.path = itemPath; // 存储数组项的路径
255
+ itemDiv.dataset.type = 'object';
256
+ itemDiv.innerHTML = `<strong>规则 ${index + 1}:</strong>`; // 标示第几个规则
257
+
258
+ // ** 添加频道内部的“删除规则”按钮 **
259
+ const deleteRuleBtn = createButton('删除', 'delete-button', (e) => {
260
+ e.stopPropagation();
261
+ deleteTitleChangeRule(titleChangePath, index); // 传递频道内部的 title_change 路径和索引
262
+ });
263
+ itemDiv.appendChild(deleteRuleBtn);
264
+
265
+ // 渲染数组中每个对象的属性
266
+ renderConfig(item, itemDiv, itemPath);
267
+ titleChangeListDiv.appendChild(itemDiv);
268
+ });
269
+ } else {
270
+ if (titleChangeListDiv.childElementCount <= 2) { // label 和 add button
271
+ titleChangeListDiv.innerHTML += '<p style="font-style: italic; color: #777; margin-left: 10px;">无标题修改规则。点击上方“添加规则”按钮。</p>';
272
+ }
273
+ }
274
+ channelContentDiv.appendChild(titleChangeListDiv); // 将 title_change 列表添加到频道内容中
275
+ }
276
+
277
+
278
+ channelSectionDiv.appendChild(channelContentDiv);
279
+
280
+ contentDiv.appendChild(channelSectionDiv);
281
+ });
282
+ } else {
283
+ contentDiv.innerHTML = '<p style="font-style: italic; color: #777; margin-left: 10px;">无配置或配置为空。请点击“添加”按钮。</p>';
284
+ }
285
+ } else {
286
+ contentDiv.innerHTML = '<p style="font-style: italic; color: #777; margin-left: 10px;">数据格式错误或无配置。请点击“添加”按钮。</p>';
287
+ }
288
+
289
+
290
+ sectionDiv.appendChild(contentDiv);
291
+ parentElement.appendChild(sectionDiv);
292
+
293
+ }
294
+ // ** 移除全局 title_change 的处理 **
295
+ // else if (key === 'title_change' && Array.isArray(value)) { ... }
296
+ // 此部分已被移动到频道内部处理
297
+
298
+ else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
299
+ // 处理其他嵌套对象 (如果需要)
300
+ const subSectionDiv = document.createElement('div');
301
+ subSectionDiv.classList.add('config-subsection'); // 可以添加特定样式
302
+ subSectionDiv.style.marginLeft = '15px'; // 缩进
303
+ subSectionDiv.style.borderLeft = '2px solid #eee';
304
+ subSectionDiv.style.paddingLeft = '10px';
305
+ subSectionDiv.dataset.path = path;
306
+ subSectionDiv.dataset.type = 'object';
307
+
308
+ const label = document.createElement('label');
309
+ label.textContent = `${key}:`;
310
+ subSectionDiv.appendChild(label);
311
+ renderConfig(value, subSectionDiv, path); // 递归渲染
312
+ parentElement.appendChild(subSectionDiv);
313
+
314
+ } else {
315
+ // 基本类型 (string, number, boolean, null)
316
+ const inputElement = createInputElement(key, value, path);
317
+ parentElement.appendChild(inputElement);
318
+ }
319
+ });
320
+ }
321
+
322
+ // --- Function: 添加频道 ---
323
+ function addChannel(sectionPath) { // e.g., 'media.channelid_youtube'
324
+ if (!currentConfig) return;
325
+
326
+ const pathParts = sectionPath.split('.');
327
+ let target = currentConfig;
328
+ for (const part of pathParts) {
329
+ if (!target[part]) {
330
+ target[part] = {}; // 如果路径不存在,创建对象
331
+ }
332
+ target = target[part];
333
+ }
334
+
335
+ const tempKey = generateTempKey('new_channel_'); // 使用临时 key 作为内部标识
336
+ // 添加一个默认的频道配置对象,包含临时 key 和空的 title_change 数组
337
+ target[tempKey] = {
338
+ _tempKey: tempKey, // 存储临时 key 用于 DOM 渲染和查找
339
+ id: '', // 用户需要填写的 ID
340
+ name: '',
341
+ media: 'm4a',
342
+ link: '',
343
+ icon: '',
344
+ description: '',
345
+ title_change: [] // 添加空的 title_change 数组
346
+ };
347
+
348
+ // 重新渲染配置表单
349
+ renderConfig(currentConfig, configContainer);
350
+ // 展开新添加的频道部分 (可选)
351
+ const newChannelElement = configContainer.querySelector(`.channel-item[data-path="${sectionPath}.${tempKey}"]`);
352
+ if(newChannelElement) {
353
+ const header = newChannelElement.querySelector('.collapsible-header');
354
+ if (header) header.click(); // 模拟点击展开
355
+ // 滚动到新添加的元素位置 (可选)
356
+ newChannelElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
357
+ }
358
+ }
359
+
360
+ // --- Function: 删除频道 ---
361
+ function deleteChannel(sectionPath, channelKey) { // e.g., 'media.channelid_youtube', 'channel_abc' or 'new_channel_123...'
362
+ if (!currentConfig) return;
363
+
364
+ const pathParts = sectionPath.split('.');
365
+ let target = currentConfig;
366
+ for (const part of pathParts) {
367
+ if (!target[part]) {
368
+ console.error("删除频道失败: 路径不存在", sectionPath);
369
+ return;
370
+ }
371
+ target = target[part];
372
+ }
373
+
374
+ if (target[channelKey]) {
375
+ // 如果是临时 key,并且用户未输入 ID,直接删除无需确认
376
+ const isTempKey = channelKey.startsWith('new_channel_');
377
+ const confirmMsg = isTempKey ? `确定要删除新的频道条目吗?` : `确定要删除频道 "${target[channelKey].id || channelKey}" 吗?`;
378
+
379
+ if (isTempKey || confirm(confirmMsg)) {
380
+ delete target[channelKey];
381
+ // 重新渲染配置表单
382
+ renderConfig(currentConfig, configContainer);
383
+ }
384
+ } else {
385
+ console.error("删除频道失败: 频道 key 不存在", channelKey);
386
+ }
387
+ }
388
+
389
+
390
+ // --- Function: 添加标题修改规则 ---
391
+ // 此函数现在需要知道是哪个频道下的 title_change
392
+ function addTitleChangeRule(titleChangePath) { // e.g., 'media.channelid_youtube.some_channel_key.title_change'
393
+ if (!currentConfig) return;
394
+
395
+ const pathParts = titleChangePath.split('.');
396
+ let target = currentConfig;
397
+ for (let i = 0; i < pathParts.length; i++) {
398
+ const part = pathParts[i];
399
+ // 处理数组索引 [index]
400
+ const match = part.match(/^([^\[]+)\[(\d+)\]$/);
401
+ if (match) {
402
+ const arrayKey = match[1];
403
+ const index = parseInt(match[2]);
404
+ if (!target[arrayKey] || !Array.isArray(target[arrayKey]) || index >= target[arrayKey].length) {
405
+ console.error("添加规则失败: 路径中的数组或索引无效", titleChangePath);
406
+ return;
407
+ }
408
+ target = target[arrayKey][index];
409
+ } else {
410
+ if (!target[part]) {
411
+ // 如果路径不存在,并且是 title_change 本身,创建数组
412
+ if (i === pathParts.length - 1 && part === 'title_change') {
413
+ target[part] = [];
414
+ } else {
415
+ console.error("添加规则失败: 路径不存在", titleChangePath);
416
+ return;
417
+ }
418
+ } else if (i === pathParts.length - 1 && part === 'title_change' && !Array.isArray(target[part])) {
419
+ console.error("添加规则失败: 目标路径不是数组", titleChangePath);
420
+ return;
421
+ }
422
+ target = target[part];
423
+ }
424
+ }
425
+
426
+
427
+ // 目标 target 现在是 title_change 数组
428
+ // 添加一个默认的规则对象
429
+ target.push({
430
+ pattern: '',
431
+ mode: 'replace', // 默认模式
432
+ value: ''
433
+ });
434
+
435
+ // 重新渲染配置表单
436
+ renderConfig(currentConfig, configContainer);
437
+ // 滚动到新添加的规则位置 (可选)
438
+ // 需要找到对应的频道 item,然后找到 title_change list,再找到最后一个 rule item
439
+ const channelItemElement = configContainer.querySelector(`.channel-item[data-path="${titleChangePath.substring(0, titleChangePath.lastIndexOf('.'))}"]`);
440
+ if(channelItemElement) {
441
+ const ruleItems = channelItemElement.querySelectorAll('.title-change-item');
442
+ if (ruleItems.length > 0) {
443
+ ruleItems[ruleItems.length - 1].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
444
+ }
445
+ }
446
+ }
447
+
448
+ // --- Function: 删除标题修改规则 ---
449
+ // 此函数现在需要知道是哪个频道下的 title_change 数组以及要删除的索引
450
+ function deleteTitleChangeRule(titleChangePath, index) { // e.g., 'media.channelid_youtube.some_channel_key.title_change', 0
451
+ if (!currentConfig) return;
452
+
453
+ const pathParts = titleChangePath.split('.');
454
+ let target = currentConfig;
455
+ for (let i = 0; i < pathParts.length; i++) {
456
+ const part = pathParts[i];
457
+ // 处理数组索引 [index]
458
+ const match = part.match(/^([^\[]+)\[(\d+)\]$/);
459
+ if (match) {
460
+ const arrayKey = match[1];
461
+ const arrIndex = parseInt(match[2]);
462
+ if (!target[arrayKey] || !Array.isArray(target[arrayKey]) || arrIndex >= target[arrayKey].length) {
463
+ console.error("删除规则失败: 路径中的数组或索引无效", titleChangePath);
464
+ return;
465
+ }
466
+ target = target[arrayKey][arrIndex];
467
+ } else {
468
+ if (!target[part] || !Array.isArray(target[part])) {
469
+ console.error("删除规则失败: 路径不是数组或不存在", titleChangePath);
470
+ return;
471
+ }
472
+ target = target[part];
473
+ }
474
+ }
475
+ // 目标 target 现在是 title_change 数组
476
+
477
+ if (index >= 0 && index < target.length) {
478
+ if (confirm(`确定要删除规则 ${index + 1} 吗?`)) {
479
+ target.splice(index, 1);
480
+ // 重新渲染配置表单
481
+ renderConfig(currentConfig, configContainer);
482
+ }
483
+ } else {
484
+ console.error("删除规则失败: 无效索引", index);
485
+ }
486
+ }
487
+
488
+
489
+ // --- Helper Function: 从表单收集数据并重建 JSON ---
490
+ function collectConfigData() {
491
+ const newConfig = {};
492
+ // 从顶层开始构建,找到主要的 section(如 media)
493
+ configContainer.querySelectorAll(':scope > .collapsible-section, :scope > .config-subsection').forEach(sectionElement => {
494
+ const path = sectionElement.dataset.path; // e.g., 'media'
495
+ const key = path.split('.')[0]; // Get the top-level key
496
+
497
+ if (sectionElement.classList.contains('collapsible-section') && (key === 'media')) {
498
+ // Handle the media section specifically to traverse into channelid_
499
+ const mediaObj = {};
500
+ newConfig[key] = mediaObj;
501
+ sectionElement.querySelectorAll(':scope > .collapsible-content > .collapsible-section').forEach(channelSection => {
502
+ const channelSectionPath = channelSection.dataset.path; // e.g., 'media.channelid_youtube' or 'media.channelid_bilibili'
503
+ const channelListKey = channelSectionPath.split('.').pop(); // e.g., 'channelid_youtube'
504
+ if (!mediaObj[channelListKey]) {
505
+ mediaObj[channelListKey] = {};
506
+ }
507
+
508
+ channelSection.querySelectorAll(':scope > .collapsible-content > .collapsible-section.channel-item').forEach(channelItemElement => {
509
+ // ** 特殊处理频道项 **
510
+ // 找到内部的 'id' 输入框,用其值作为 key
511
+ const idInput = channelItemElement.querySelector('[data-path$=".id"]'); // 查找路径以 ".id" 结尾的输入框
512
+ const channelId = idInput ? idInput.value.trim() : null; // 获取用户输入的 ID
513
+
514
+ if (channelId) {
515
+ const channelObj = {};
516
+ // 递归收集频道内部的配置
517
+ const channelContentDiv = channelItemElement.querySelector('.collapsible-content');
518
+ if (channelContentDiv) {
519
+ // Collect data recursively from within the channel content
520
+ // Pass the full path including the temporary key for nested items' data-path lookups
521
+ reconstructObject(channelContentDiv, channelObj, channelItemElement.dataset.path);
522
+
523
+ // Avoid adding _tempKey to the final output
524
+ delete channelObj._tempKey;
525
+ mediaObj[channelListKey][channelId] = channelObj;
526
+ } else {
527
+ console.warn(`Channel item content not found for path: ${channelItemElement.dataset.path}`);
528
+ }
529
+
530
+ } else {
531
+ console.warn(`Skipping channel with empty ID in path: ${channelItemElement.dataset.path}`);
532
+ }
533
+ });
534
+ });
535
+
536
+
537
+ } else if (sectionElement.dataset.type === 'object') {
538
+ // Handle other top-level objects
539
+ const subObj = {};
540
+ newConfig[key] = subObj;
541
+ // Recursively collect data from within this subsection
542
+ reconstructObject(sectionElement, subObj, path);
543
+ }
544
+ // Note: Top-level non-object/array items are collected by reconstructObject called from here
545
+ });
546
+
547
+
548
+ // Recursive helper to collect data from a given parent element starting at a path
549
+ function reconstructObject(parentElement, currentObj, pathPrefix = '') {
550
+ // Collect direct children inputs within config-item divs
551
+ parentElement.querySelectorAll(`:scope > .config-item`).forEach(itemDiv => {
552
+ const input = itemDiv.querySelector('[data-path]');
553
+ if (input) {
554
+ const path = input.dataset.path;
555
+ // Ensure this input belongs to the current object/array item based on pathPrefix
556
+ // Examples: pathPrefix="media.channelid_youtube.temp_abc", path="media.channelid_youtube.temp_abc.name" -> key="name"
557
+ // Examples: pathPrefix="media.channelid_youtube.temp_abc.title_change[0]", path="media.channelid_youtube.temp_abc.title_change[0].pattern" -> key="pattern"
558
+ // Examples: pathPrefix="", path="global_setting" -> key="global_setting"
559
+ if (path === pathPrefix || path.startsWith(pathPrefix + '.') || path.match(new RegExp(`^${pathPrefix}\\[\\d+\\]`))) {
560
+ let relativePath = path.substring(pathPrefix.length > 0 ? pathPrefix.length + 1 : 0);
561
+ // If the path looks like "title_change[0].pattern", split by '.' first
562
+ const pathParts = relativePath.split('.');
563
+ const key = pathParts[0]; // Get the first part as the key for the current level
564
+
565
+ // Handle array indices within the key itself (e.g., title_change[0])
566
+ const keyMatch = key.match(/^([^\[]+)\[(\d+)\]$/);
567
+ if (keyMatch) {
568
+ // This case is handled when iterating through .title-change-item sections, not here
569
+ // Inputs within array items will have a path like title_change[0].pattern
570
+ // We need to ensure the recursive call correctly builds the array item first
571
+ } else {
572
+ // Simple key
573
+ if (!relativePath.includes('[') && !relativePath.includes('.')) { // Only direct keys relative to pathPrefix
574
+ const type = input.dataset.type;
575
+ let value;
576
+ if (input.tagName === 'INPUT') {
577
+ if (type === 'boolean') value = input.checked;
578
+ else if (type === 'number') value = parseFloat(input.value) || (input.value === '0' ? 0 : null);
579
+ else value = input.value;
580
+ } else if (input.tagName === 'SELECT') {
581
+ value = input.value;
582
+ }
583
+ currentObj[key] = value;
584
+ }
585
+ }
586
+ }
587
+ }
588
+ });
589
+
590
+ // Collect direct children sections or lists recursively
591
+ parentElement.querySelectorAll(`:scope > .collapsible-section, :scope > .config-subsection, :scope > .title-change-list`).forEach(sectionElement => {
592
+ const path = sectionElement.dataset.path;
593
+ // Ensure this section belongs to the current object/array item based on pathPrefix
594
+ if (path === pathPrefix || path.startsWith(pathPrefix + '.') || path.match(new RegExp(`^${pathPrefix}\\[\\d+\\]`))) {
595
+
596
+ let relativePath = path.substring(pathPrefix.length > 0 ? pathPrefix.length + 1 : 0);
597
+ const pathParts = relativePath.split('.');
598
+ const key = pathParts[0]; // Get the first part as the key for the current level
599
+
600
+ // Handle array indices in the key (e.g., title_change[0])
601
+ const keyMatch = key.match(/^([^\[]+)\[(\d+)\]$/);
602
+
603
+ if (sectionElement.classList.contains('channel-item')) {
604
+ // Channel items are handled at the higher level (inside media.channelid_*)
605
+ // Do nothing here to avoid processing them again
606
+ } else if (sectionElement.classList.contains('title-change-list')) {
607
+ // ** Special handling for nested title_change array **
608
+ const listKey = key; // 'title_change'
609
+ const listArray = [];
610
+ sectionElement.querySelectorAll(':scope > .title-change-item').forEach(itemDiv => {
611
+ const itemPath = itemDiv.dataset.path; // e.g., media.channelid_youtube.temp_abc.title_change[0]
612
+ const itemObj = {};
613
+ // Recursively collect data for the array item
614
+ reconstructObject(itemDiv, itemObj, itemPath);
615
+ listArray.push(itemObj);
616
+ });
617
+ currentObj[listKey] = listArray;
618
+
619
+ } else if (sectionElement.dataset.type === 'object' && !keyMatch) {
620
+ // Handle other nested objects (subsections)
621
+ const subObj = {};
622
+ currentObj[key] = subObj;
623
+ reconstructObject(sectionElement, subObj, path); // Recursively collect data
624
+ } else if (keyMatch && sectionElement.dataset.type === 'object') {
625
+ // This is an item within an array (like title_change[0]), handled above
626
+ // Ensure it's not double-processed
627
+ }
628
+ }
629
+ });
630
+
631
+ return currentObj;
632
+ }
633
+
634
+ // Cleanup temporary keys from the collected data before returning
635
+ function cleanTempKeys(obj) {
636
+ if (Array.isArray(obj)) {
637
+ return obj.map(cleanTempKeys);
638
+ } else if (typeof obj === 'object' && obj !== null) {
639
+ const newObj = {};
640
+ for (const key in obj) {
641
+ if (key !== '_tempKey') {
642
+ newObj[key] = cleanTempKeys(obj[key]);
643
+ }
644
+ }
645
+ return newObj;
646
+ }
647
+ return obj;
648
+ }
649
+
650
+
651
+ // Note: The main collection loop outside this helper starts the process
652
+ // The result is already in newConfig
653
+
654
+ // Final cleanup of temporary keys
655
+ return cleanTempKeys(newConfig);
656
+ }
657
+
658
+
659
+ // --- Function: 加载配置 ---
660
+ async function loadConfig() {
661
+ configContainer.innerHTML = '<p>正在加载配置...</p>';
662
+ configStatus.textContent = '';
663
+ saveConfigBtn.disabled = true; // 禁用保存按钮直到加载完成
664
+ refreshConfigBtn.disabled = true; // 禁用刷新按钮直到加载完成
665
+
666
+ try {
667
+ const response = await fetch('/getconfig');
668
+ if (!response.ok) {
669
+ throw new Error(`HTTP error! status: ${response.status}`);
670
+ }
671
+ currentConfig = await response.json(); // 保存原始配置
672
+ // 在加载的配置中为现有频道添加临时 key,以便 renderConfig 正确处理
673
+ // 这对于删除现有频道条目是必要的,因为 deleteChannel 使用的是 currentConfig 中的 key
674
+ // 同时确保每个频道有 title_change 数组,即使是空的
675
+ if (currentConfig?.media?.channelid_youtube) {
676
+ for (const key in currentConfig.media.channelid_youtube) {
677
+ currentConfig.media.channelid_youtube[key]._tempKey = key;
678
+ if (!Array.isArray(currentConfig.media.channelid_youtube[key].title_change)) {
679
+ currentConfig.media.channelid_youtube[key].title_change = [];
680
+ }
681
+ }
682
+ }
683
+ if (currentConfig?.media?.channelid_bilibili) {
684
+ for (const key in currentConfig.media.channelid_bilibili) {
685
+ currentConfig.media.channelid_bilibili[key]._tempKey = key;
686
+ if (!Array.isArray(currentConfig.media.channelid_bilibili[key].title_change)) {
687
+ currentConfig.media.channelid_bilibili[key].title_change = [];
688
+ }
689
+ }
690
+ }
691
+
692
+ configContainer.innerHTML = ''; // 清空加载提示
693
+ renderConfig(currentConfig, configContainer);
694
+ saveConfigBtn.disabled = false; // 启用保存按钮
695
+ } catch (error) {
696
+ console.error('加载配置失败:', error);
697
+ configContainer.innerHTML = `<p style="color: red;">加载配置失败: ${error.message}</p>`;
698
+ currentConfig = null; // 清除可能不完整的配置
699
+ } finally {
700
+ refreshConfigBtn.disabled = false; // 总是重新启用刷新按钮
701
+ }
702
+ }
703
+
704
+ // --- Function: 保存配置 ---
705
+ async function saveConfig() {
706
+ const updatedConfig = collectConfigData();
707
+ console.log("Collected config:", JSON.stringify(updatedConfig, null, 2)); // 打印收集到的数据以供调试
708
+
709
+ configStatus.textContent = '正在保存...';
710
+ configStatus.style.color = 'orange';
711
+ saveConfigBtn.disabled = true;
712
+ refreshConfigBtn.disabled = true;
713
+
714
+ try {
715
+ // *** 假设你的保存端点是 /saveconfig ***
716
+ const response = await fetch('/saveconfig', {
717
+ method: 'POST',
718
+ headers: {
719
+ 'Content-Type': 'application/json',
720
+ },
721
+ body: JSON.stringify(updatedConfig, null, 2), // 格式化 JSON 便于后端读取和调试
722
+ });
723
+
724
+ if (!response.ok) {
725
+ // 尝试读取错误信息
726
+ let errorMsg = `HTTP error! status: ${response.status}`;
727
+ try {
728
+ const errorData = await response.json(); // 假设后端返回 JSON 错误信息
729
+ errorMsg = errorData.detail || errorData.message || JSON.stringify(errorData);
730
+ } catch (e) {
731
+ // If response is not JSON or reading fails
732
+ errorMsg = await response.text() || errorMsg;
733
+ }
734
+
735
+ throw new Error(errorMsg);
736
+ }
737
+
738
+ // 保存成功后可以重新加载,以确认更改生效
739
+ configStatus.textContent = '配置保存成功!正在刷新...';
740
+ configStatus.style.color = 'green';
741
+ // 稍作延迟再加载,让用户看到成功信息
742
+ setTimeout(loadConfig, 1500);
743
+
744
+ } catch (error) {
745
+ console.error('保存配置失败:', error);
746
+ configStatus.textContent = `保存失败: ${error.message}`;
747
+ configStatus.style.color = 'red';
748
+ saveConfigBtn.disabled = false; // 重新启用保存按钮
749
+ refreshConfigBtn.disabled = false; // 重新启用刷新按钮
750
+ }
751
+ }
752
+
753
+ // --- 事件监听 ---
754
+ refreshConfigBtn.addEventListener('click', loadConfig);
755
+ saveConfigBtn.addEventListener('click', saveConfig);
756
+
757
+ // --- 页面切换逻辑 (需要集成到你现有的 index.js 逻辑中) ---
758
+ // This is an example, you need to adjust based on your index.js file
759
+ function showPage(pageId) {
760
+ document.querySelectorAll('main > section').forEach(section => {
761
+ section.style.display = 'none';
762
+ });
763
+ const pageToShow = document.getElementById(pageId);
764
+ if (pageToShow) {
765
+ pageToShow.style.display = 'block';
766
+ // 如果切换到配置页面,并且尚未加载,则加载配置
767
+ if (pageId === 'pageConfig' && !currentConfig) {
768
+ loadConfig();
769
+ } else if (pageId === 'pageConfig' && currentConfig) {
770
+ // 如果已经加载过配置,切换回来时重新渲染一次以确保状态正确
771
+ // Ensure temporary keys are present before re-rendering if needed
772
+ if (currentConfig?.media?.channelid_youtube) {
773
+ for (const key in currentConfig.media.channelid_youtube) {
774
+ if (!currentConfig.media.channelid_youtube[key]._tempKey) {
775
+ currentConfig.media.channelid_youtube[key]._tempKey = key;
776
+ }
777
+ if (!Array.isArray(currentConfig.media.channelid_youtube[key].title_change)) {
778
+ currentConfig.media.channelid_youtube[key].title_change = [];
779
+ }
780
+ }
781
+ }
782
+ if (currentConfig?.media?.channelid_bilibili) {
783
+ for (const key in currentConfig.media.channelid_bilibili) {
784
+ if (!currentConfig.media.channelid_bilibili[key]._tempKey) {
785
+ currentConfig.media.channelid_bilibili[key]._tempKey = key;
786
+ }
787
+ if (!Array.isArray(currentConfig.media.channelid_bilibili[key].title_change)) {
788
+ currentConfig.media.channelid_bilibili[key].title_change = [];
789
+ }
790
+ }
791
+ }
792
+ renderConfig(currentConfig, configContainer);
793
+ }
794
+ // 更新菜单激活状态 (可选)
795
+ document.querySelectorAll('#menu li').forEach(li => {
796
+ li.classList.toggle('active', li.dataset.page === pageId);
797
+ });
798
+ }
799
+ }
800
+
801
+ // Listen for menu clicks (ensure your menu items have data-page attributes)
802
+ document.querySelectorAll('#menu li').forEach(item => {
803
+ item.addEventListener('click', () => {
804
+ const pageId = item.getAttribute('data-page');
805
+ if (pageId) {
806
+ showPage(pageId);
807
+ }
808
+ });
809
+ });
810
+
811
+ // --- Menu toggle button logic (reuse your existing logic if any) ---
812
+ const toggleMenuBtn = document.getElementById('toggleMenu');
813
+ const menuNav = document.getElementById('menu');
814
+ const mainContent = document.getElementById('main'); // Get main element
815
+ if (toggleMenuBtn && menuNav && mainContent) {
816
+ toggleMenuBtn.addEventListener('click', () => {
817
+ menuNav.classList.toggle('closed'); // Assume 'closed' class is used to hide the menu
818
+ mainContent.classList.toggle('menu-closed'); // Add class to main to adjust margin
819
+ toggleMenuBtn.textContent = menuNav.classList.contains('closed') ? '❯' : '❮';
820
+ // You may need CSS for .menu.closed and main.menu-closed
821
+ });
822
+ }
823
+
824
+ // --- Initial load ---
825
+ // On page load, if the current page is configPage, auto load config
826
+ if (configPage && configPage.style.display !== 'none') {
827
+ loadConfig();
828
+ }
829
+ // If you want configPage to be the default page shown, uncomment the line below
830
+ // and comment out any other default page setting in your index.js
831
+ // showPage('pageConfig'); // Example: set config page as default
832
+ });