Files
Henry_Du 5e466f4553 fix(ui): 补齐 generator.html 和 listmanager.html 的问号剥离兜底
- generator.html: 新增 ds() 钩子,在 renderList / batch 预览 / showToast 三个渲染点统一问号剥离
- listmanager.html: 新增 ds() 钩子,在 renderList / renderSequence / 自定义 select 选项 / showToast 四个点统一问号剥离
- APP_VERSION_TEXT bump 到 v1.0.107
2026-08-17 17:32:01 +08:00

753 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>list.txt Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #121214;
--bg-hover: #27272a;
--bg-border: #27272a;
--bg-input: #09090b;
--text-primary: #f4f4f5;
--text-muted: #a1a1aa;
--danger: #ef4444;
--success: #10b981;
--radius: 8px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--ring-color: rgba(255, 255, 255, 0.2);
}
* { box-sizing: border-box; }
body {
font-family: var(--font-ui);
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
color: var(--text-primary);
margin: 0;
padding: 30px 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.layout-wrapper {
width: 100%;
max-width: 980px;
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
border-bottom: 1px solid var(--bg-border);
gap: 12px;
flex-wrap: wrap;
}
.header h2 {
margin: 0;
font-weight: 600;
letter-spacing: -0.025em;
background: linear-gradient(to right, #fff, #a1a1aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
}
.panel-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
padding-bottom: 12px;
border-bottom: 1px solid var(--bg-border);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.btn {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
user-select: none;
text-decoration: none;
}
.btn:hover:not(:disabled) { background: var(--bg-hover); }
.btn-primary {
background: var(--text-primary);
color: var(--bg-body);
border-color: var(--text-primary);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background: #d4d4d8;
border-color: #d4d4d8;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.list-container {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 65vh;
overflow-y: auto;
padding-right: 4px;
}
.list-item {
display: grid;
grid-template-columns: 110px 1fr auto;
gap: 14px;
align-items: center;
background: var(--bg-input);
padding: 12px 14px;
border: 1px solid var(--bg-border);
border-radius: 6px;
transition: border-color 0.2s;
}
.list-item:hover { border-color: #52525b; }
.code {
font-family: var(--font-mono);
font-weight: 600;
white-space: nowrap;
}
.tag {
display: inline-flex;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-hover);
font-size: 0.8rem;
margin-right: 4px;
margin-bottom: 4px;
font-family: var(--font-mono);
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover { color: var(--text-primary); background: var(--bg-hover); }
.btn-icon.danger:hover { color: var(--danger); background: rgba(239, 68, 68, 0.1); }
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
backdrop-filter: blur(6px);
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 12px;
width: 92%;
max-width: 540px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.7);
}
.modal-header {
padding: 18px 20px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 1.05rem;
}
.modal-body {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--bg-border);
display: flex;
justify-content: flex-end;
gap: 10px;
background: rgba(0,0,0,0.2);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.form-group { display: flex; flex-direction: column; gap: 8px; }
label { font-size: 0.875rem; font-weight: 500; }
input[type="text"] {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 0.95rem;
outline: none;
transition: all 0.2s;
}
input[type="text"]:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.edit-files-list {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-input);
border: 1px solid var(--bg-border);
border-radius: 6px;
padding: 12px;
min-height: 120px;
max-height: 260px;
overflow-y: auto;
}
.edit-file-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-panel);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--bg-border);
transition: all 0.2s;
gap: 10px;
}
.edit-file-name {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-primary);
word-break: break-all;
}
.edit-file-actions { display: flex; gap: 6px; }
.custom-select-container {
position: relative;
width: 100%;
font-family: var(--font-ui);
font-size: 0.95rem;
user-select: none;
}
.custom-select-trigger {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.custom-select-trigger.active {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.custom-select-options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 6px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
z-index: 150;
max-height: 240px;
overflow-y: auto;
display: none;
flex-direction: column;
padding: 4px;
}
.custom-select-options.active { display: flex; }
.custom-select-option {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
color: var(--text-primary);
transition: background 0.1s;
font-family: var(--font-mono);
font-size: 0.85rem;
}
.custom-select-option:hover { background: var(--bg-hover); }
.custom-select-option.selected { background: var(--bg-hover); font-weight: 500; }
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
padding: 14px 18px;
border-radius: 8px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
animation: slideIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideIn {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-input); border-radius: 4px; }
::-webkit-scrollbar-thumb { background: #52525b; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #71717a; }
* { scrollbar-width: thin; scrollbar-color: #52525b var(--bg-input); }
</style>
</head>
<body>
<div class="layout-wrapper">
<div class="header">
<h2>list.txt 管理</h2>
<div class="header-actions">
<a class="btn" href="/">控制台</a>
<a class="btn" href="/generator.html">Generator</a>
</div>
</div>
<div class="panel">
<div class="panel-subtitle">对 /opt/CC-StationBroadcast/list.txt 进行增删改;每条记录格式:站号==音频文件1,音频文件2,... (支持多个站号用 / 分隔)</div>
<div class="toolbar">
<button class="btn" onclick="openAddModal()">添加</button>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="btn" onclick="reloadAll()">刷新</button>
<button class="btn btn-primary" id="btn-save" onclick="saveList()">保存到服务器</button>
</div>
</div>
<div class="list-container" id="list-container">
<div style="text-align:center; padding: 40px; color:var(--text-muted)">Loading...</div>
</div>
</div>
</div>
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-header">
<span id="modal-title">Edit</span>
<button class="btn-icon" onclick="closeModal()" aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>站号</label>
<input type="text" id="edit-code" placeholder="例如:01-01 / 01-01/01-02" />
</div>
<div class="form-group">
<label>播放序列(从上到下)</label>
<div class="edit-files-list" id="edit-files-list"></div>
</div>
<div class="form-group">
<label>添加音频文件</label>
<div style="display:flex; gap:10px; align-items:center;">
<div class="custom-select-container" id="custom-file-select">
<div class="custom-select-trigger" onclick="toggleCustomSelect()">
<span id="custom-select-label">-- Select an audio file --</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
<div class="custom-select-options" id="custom-select-options"></div>
</div>
<button class="btn" onclick="addFileToSequence()">添加</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn btn-primary" onclick="confirmEdit()">确定</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
function ds(x) {
let v = String(x == null ? '' : x)
.replace(/[?¿?]+/g, '')
.replace(/\s+/g, ' ')
.trim();
return v === '' ? '-' : v;
}
const ICONS = {
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
trash: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`
};
let listData = [];
let availableFiles = [];
let editingIndex = -1;
let currentSequence = [];
let selectedFileValue = '';
function showToast(title, message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div>
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${ds(title)}</div>
<div style="font-size:0.8rem; color:var(--text-muted)">${ds(message)}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 250);
}, 2500);
}
function toggleCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
const next = !options.classList.contains('active');
trigger.classList.toggle('active', next);
options.classList.toggle('active', next);
}
function closeCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
trigger.classList.remove('active');
options.classList.remove('active');
}
function selectCustomOption(value, label) {
selectedFileValue = value || '';
const labelEl = document.getElementById('custom-select-label');
if (labelEl) labelEl.textContent = label || '-- Select an audio file --';
const optionsContainer = document.getElementById('custom-select-options');
if (optionsContainer) {
Array.from(optionsContainer.querySelectorAll('.custom-select-option')).forEach(el => {
el.classList.toggle('selected', (el.getAttribute('data-value') || '') === selectedFileValue);
});
}
closeCustomSelect();
}
document.addEventListener('click', (e) => {
const container = document.getElementById('custom-file-select');
if (!container) return;
if (!container.contains(e.target)) closeCustomSelect();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeCustomSelect();
});
async function fetchAvailableFiles() {
const res = await fetch('/api/files');
const data = await res.json();
availableFiles = (data.files || [])
.filter(f => f.type === 'station' || f.type === 'general')
.map(f => f.name)
.sort();
populateFileSelect();
}
function populateFileSelect() {
const optionsContainer = document.getElementById('custom-select-options');
if (!optionsContainer) return;
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
availableFiles.forEach(f => {
const isSelected = selectedFileValue === f ? 'selected' : '';
const dsF = ds(f);
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${dsF}')">${dsF}</div>`;
});
optionsContainer.innerHTML = html;
}
async function loadList() {
const res = await fetch('/api/files/content?path=list.txt');
const data = await res.json();
listData = [];
if (data.success && data.content) {
const lines = String(data.content).split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([^=]+)==(.+)$/);
if (match) {
listData.push({ code: match[1], files: match[2].split(',').filter(Boolean) });
}
}
}
renderList();
}
function renderList() {
const container = document.getElementById('list-container');
container.innerHTML = '';
listData.sort((a, b) => String(a.code).localeCompare(String(b.code)));
if (listData.length === 0) {
container.innerHTML = '<div style="text-align:center; padding: 40px; color:var(--text-muted)">No entries</div>';
return;
}
listData.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'list-item';
const dsCode = ds(item.code);
const tagsHtml = (item.files || []).map(f => `<span class="tag">${ds(f)}</span>`).join(' ');
div.innerHTML = `
<div class="code">${dsCode}</div>
<div>${tagsHtml || '<span style="color:var(--text-muted); font-style: italic;">No files</span>'}</div>
<div style="display:flex; gap:6px;">
<button class="btn-icon" onclick="openEditModal(${index})" aria-label="Edit">${ICONS.edit}</button>
<button class="btn-icon danger" onclick="removeListItem(${index})" aria-label="Delete">${ICONS.trash}</button>
</div>
`;
container.appendChild(div);
});
}
function removeListItem(index) {
const code = listData[index]?.code;
if (!code) return;
if (!confirm(`删除 ${code} ?`)) return;
listData.splice(index, 1);
renderList();
}
function openAddModal() {
editingIndex = -1;
currentSequence = [];
document.getElementById('modal-title').textContent = '添加站号配置';
document.getElementById('edit-code').value = '';
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function openEditModal(index) {
editingIndex = index;
const item = listData[index];
currentSequence = [...(item?.files || [])];
document.getElementById('modal-title').textContent = `编辑:${item?.code || ''}`;
document.getElementById('edit-code').value = item?.code || '';
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function closeModal() {
closeCustomSelect();
document.getElementById('edit-modal').classList.remove('active');
}
function renderSequence() {
const list = document.getElementById('edit-files-list');
list.innerHTML = '';
if (currentSequence.length === 0) {
list.innerHTML = '<div style="color:var(--text-muted); font-size:0.85rem; text-align:center; padding:10px;">No files added yet</div>';
return;
}
currentSequence.forEach((file, idx) => {
const div = document.createElement('div');
div.className = 'edit-file-item';
div.innerHTML = `
<div class="edit-file-name">${ds(file)}</div>
<div class="edit-file-actions">
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.35"' : ''} aria-label="Up">${ICONS.up}</button>
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.35"' : ''} aria-label="Down">${ICONS.down}</button>
<button class="btn-icon danger" onclick="removeFileFromSequence(${idx})" aria-label="Remove">${ICONS.trash}</button>
</div>
`;
list.appendChild(div);
});
}
function addFileToSequence() {
const file = selectedFileValue;
if (!file) return;
currentSequence.push(file);
renderSequence();
selectCustomOption('', '-- Select an audio file --');
}
function removeFileFromSequence(idx) {
currentSequence.splice(idx, 1);
renderSequence();
}
function moveFile(idx, dir) {
if (idx + dir < 0 || idx + dir >= currentSequence.length) return;
const temp = currentSequence[idx];
currentSequence[idx] = currentSequence[idx + dir];
currentSequence[idx + dir] = temp;
renderSequence();
}
function confirmEdit() {
const code = document.getElementById('edit-code').value.trim();
if (!code) {
alert('站号不能为空');
return;
}
if (editingIndex === -1) {
if (listData.some(item => item.code === code)) {
alert(`站号 ${code} 已存在`);
return;
}
listData.push({ code, files: [...currentSequence] });
} else {
listData[editingIndex].code = code;
listData[editingIndex].files = [...currentSequence];
}
renderList();
closeModal();
}
async function saveList() {
const btn = document.getElementById('btn-save');
btn.disabled = true;
btn.textContent = 'Saving...';
const content = listData
.filter(item => item.code)
.map(item => `${item.code}==${(item.files || []).join(',')}`)
.join('\n');
try {
const res = await fetch('/api/files/content', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: 'list.txt', content: content + '\n' })
});
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Save failed');
showToast('Success', 'list.txt saved');
} catch (e) {
showToast('Error', e?.message || String(e));
} finally {
btn.disabled = false;
btn.textContent = '保存到服务器';
}
}
async function reloadAll() {
try {
await fetchAvailableFiles();
await loadList();
showToast('OK', 'Reloaded');
} catch (e) {
showToast('Error', e?.message || String(e));
}
}
async function init() {
try {
await fetchAvailableFiles();
await loadList();
} catch (e) {
showToast('Error', e?.message || String(e));
}
}
init();
</script>
</body>
</html>