podflow 20250430__py3-none-any.whl → 20250526__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.
- podflow/__init__.py +1 -0
- podflow/basic/file_save.py +12 -8
- podflow/basic/http_client.py +5 -5
- podflow/config/correct_config.py +5 -0
- podflow/httpfs/app_bottle.py +18 -3
- podflow/templates/css/config.css +225 -0
- podflow/templates/index.html +18 -6
- podflow/templates/js/config.js +832 -0
- podflow/upload/linked_client.py +68 -27
- podflow/upload/upload_files.py +58 -0
- {podflow-20250430.dist-info → podflow-20250526.dist-info}/METADATA +2 -2
- {podflow-20250430.dist-info → podflow-20250526.dist-info}/RECORD +15 -12
- {podflow-20250430.dist-info → podflow-20250526.dist-info}/WHEEL +0 -0
- {podflow-20250430.dist-info → podflow-20250526.dist-info}/entry_points.txt +0 -0
- {podflow-20250430.dist-info → podflow-20250526.dist-info}/top_level.txt +0 -0
@@ -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
|
+
});
|