tgstate-rust / app /static /js /main.js
bhgi's picture
Deploy tgstate-rust
71217a7
Raw
History Blame Contribute Delete
24.6 kB
document.addEventListener('DOMContentLoaded', () => {
// --- Global Variables ---
const uploadArea = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-picker');
const progressArea = document.getElementById('prog-zone');
const doneArea = document.getElementById('done-zone');
const searchInput = document.getElementById('file-search');
// --- Copy Link Delegation ---
document.addEventListener('click', (e) => {
const btn = e.target.closest('.copy-link-btn');
if (!btn) return;
// Prevent default if it's a link (though it's a button)
e.preventDefault();
e.stopPropagation();
const item = btn.closest('.file-item, .image-card');
if (!item) return; // Should exist
// 如果按钮上有 onclick 属性(旧代码或特殊情况),优先执行 onclick,这里不处理
if (btn.hasAttribute('onclick')) return;
const shortId = item.dataset.shortId;
const fileId = item.dataset.fileId;
const filename = item.dataset.filename;
// 核心策略:优先从 DOM 中获取真实可用的绝对 URL
let url = '';
// 1. 尝试获取下载按钮的链接 (文件列表模式)
// 查找 href 以 /d/ 开头的 a 标签
const downloadLink = item.querySelector('a[href^="/d/"]');
if (downloadLink && downloadLink.href) {
url = downloadLink.href;
}
// 2. 尝试获取图片的 src (图床模式)
if (!url) {
const img = item.querySelector('img[src^="/d/"]');
if (img && img.src) {
url = img.src;
}
}
// 3. Fallback: 使用 dataset 中的 fileUrl (如果存在且非空且不是 undefined 字符串)
if (!url) {
const dsUrl = item.dataset.fileUrl;
if (dsUrl && dsUrl !== 'undefined') {
url = dsUrl;
// 确保是绝对路径
if (url.startsWith('/')) {
url = window.location.origin + url;
}
}
}
// 4. Final Fallback: 构造 /d/{id}
if (!url || url.includes('undefined')) {
const id = (shortId && shortId !== 'None' && shortId !== '') ? shortId : fileId;
url = window.location.origin + `/d/${id}`;
}
// 安全检查:如果最终结果包含 undefined,强制重构
if (url.includes('undefined')) {
// 最后的兜底,哪怕 fileId 也是 undefined (极低概率),也比 http://...undefined 好
console.warn('Constructed URL contained undefined, falling back to raw fileId');
url = window.location.origin + '/d/' + (fileId || 'error');
}
if (window.copyLink) {
Utils.copy(url);
} else {
Utils.copy(url);
}
});
// --- Search Functionality ---
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
// Select both file list items and image grid cards
const items = document.querySelectorAll('.file-item, .image-card');
items.forEach(item => {
const name = (item.dataset.filename || '').toLowerCase();
if (name.includes(term)) {
item.style.display = ''; // Reset to default (grid or flex)
} else {
item.style.display = 'none';
}
});
});
}
// --- Upload Logic ---
if (uploadArea && fileInput) {
// Prevent double dialog by stopping propagation from input
fileInput.addEventListener('click', (e) => e.stopPropagation());
uploadArea.addEventListener('click', (e) => {
// Only trigger if not clicking the input itself (though propagation stop handles it, this is extra safety)
if (e.target !== fileInput) {
fileInput.click();
}
});
uploadArea.addEventListener('dragover', (event) => {
event.preventDefault();
uploadArea.style.borderColor = 'var(--primary-color)';
uploadArea.style.backgroundColor = 'var(--bg-surface-hover)';
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.style.borderColor = '';
uploadArea.style.backgroundColor = '';
});
uploadArea.addEventListener('drop', (event) => {
event.preventDefault();
uploadArea.style.borderColor = '';
uploadArea.style.backgroundColor = '';
const files = event.dataTransfer.files;
if (files.length > 0) {
handleFiles(files);
}
});
fileInput.addEventListener('change', ({ target }) => {
if (target.files.length > 0) {
handleFiles(target.files);
}
});
}
// Queue system for uploads
const uploadQueue = [];
let isUploading = false;
function handleFiles(files) {
if (progressArea) progressArea.innerHTML = '';
for (const file of files) {
uploadQueue.push(file);
}
processQueue();
}
function processQueue() {
if (isUploading || uploadQueue.length === 0) return;
isUploading = true;
const file = uploadQueue.shift();
uploadFile(file).then(() => {
isUploading = false;
processQueue();
});
}
function uploadFile(file) {
return new Promise((resolve) => {
const formData = new FormData();
formData.append('file', file, file.name);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload', true);
const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
// Initial Progress UI
// 使用新版 UI 风格
const progressHTML = `
<div class="card" id="progress-${fileId}" style="padding: 16px; margin-bottom: 12px; border: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="font-size: 14px; font-weight: 500;">${file.name}</span>
<span class="percent" style="font-size: 12px; color: var(--text-secondary);">0%</span>
</div>
<div style="height: 4px; background: var(--bg-surface-hover); border-radius: 2px; overflow: hidden;">
<div class="progress-bar" style="width: 0%; height: 100%; background: var(--primary-color); transition: width 0.2s;"></div>
</div>
</div>`;
if (progressArea) progressArea.insertAdjacentHTML('beforeend', progressHTML);
const progressEl = document.querySelector(`#progress-${fileId} .progress-bar`);
const percentEl = document.querySelector(`#progress-${fileId} .percent`);
xhr.upload.onprogress = ({ loaded, total }) => {
const percent = Math.floor((loaded / total) * 100);
if (progressEl) progressEl.style.width = `${percent}%`;
if (percentEl) percentEl.textContent = `${percent}%`;
};
xhr.onload = () => {
const progressRow = document.getElementById(`progress-${fileId}`);
if (progressRow) progressRow.remove();
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
const fileUrl = response.url;
// Success Toast
if (window.Toast) Toast.show(`${file.name} 上传成功`);
// Add to done area
const successHTML = `
<div class="card" style="padding: 16px; margin-bottom: 12px; border-left: 4px solid var(--success-color);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="overflow: hidden; margin-right: 12px;">
<div style="font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${file.name}</div>
<a href="${fileUrl}" target="_blank" style="font-size: 12px; color: var(--primary-color);">${fileUrl}</a>
</div>
<button class="btn btn-secondary btn-sm" onclick="Utils.copy('${fileUrl}')">复制</button>
</div>
</div>`;
if (doneArea) doneArea.insertAdjacentHTML('afterbegin', successHTML);
} else {
let errorMsg = "上传失败";
try {
const parsed = JSON.parse(xhr.responseText);
const detail = parsed && parsed.detail;
if (typeof detail === 'string') {
errorMsg = detail;
} else if (detail && typeof detail === 'object') {
errorMsg = detail.message || errorMsg;
} else if (parsed && parsed.message) {
errorMsg = parsed.message;
}
} catch (e) {}
if (window.Toast) Toast.show(errorMsg, 'error');
}
resolve();
};
xhr.onerror = () => {
const progressRow = document.getElementById(`progress-${fileId}`);
if (progressRow) progressRow.remove();
if (window.Toast) Toast.show('网络错误', 'error');
resolve();
};
xhr.send(formData);
});
}
// --- Batch Actions ---
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const batchDeleteBtn = document.getElementById('batch-delete-btn');
const copyLinksBtn = document.getElementById('copy-links-btn');
const selectionCounter = document.getElementById('selection-counter');
const batchActionsBar = document.getElementById('batch-actions-bar');
const formatOptions = document.querySelectorAll('.format-option');
function updateBatchControls() {
const checkboxes = document.querySelectorAll('.file-checkbox');
const checked = document.querySelectorAll('.file-checkbox:checked');
const count = checked.length;
if (selectionCounter) selectionCounter.textContent = count > 0 ? `${count} 项已选` : '0 项已选';
if (batchActionsBar) {
if (count > 0) {
batchActionsBar.classList.remove('hidden');
} else {
batchActionsBar.classList.add('hidden');
}
}
if (selectAllCheckbox) selectAllCheckbox.checked = (count > 0 && count === checkboxes.length);
}
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', (e) => {
document.querySelectorAll('.file-checkbox').forEach(cb => {
cb.checked = e.target.checked;
});
updateBatchControls();
});
}
// Delegation for dynamic checkboxes
document.addEventListener('change', (e) => {
if (e.target.classList.contains('file-checkbox')) {
updateBatchControls();
}
});
// Format selection (Image Hosting)
if (formatOptions) {
formatOptions.forEach(opt => {
opt.addEventListener('click', () => {
formatOptions.forEach(o => o.classList.remove('active'));
opt.classList.add('active');
});
});
}
// Batch Copy
if (copyLinksBtn) {
copyLinksBtn.addEventListener('click', () => {
const checked = document.querySelectorAll('.file-checkbox:checked');
if (checked.length === 0) return;
const activeFormatBtn = document.querySelector('.format-option.active');
const format = activeFormatBtn ? activeFormatBtn.dataset.format : 'url';
const links = Array.from(checked).map(cb => {
const item = cb.closest('.file-item, .image-card');
let url = '';
// 1. 尝试获取下载按钮
const downloadLink = item.querySelector('a[href^="/d/"]');
if (downloadLink && downloadLink.href) {
url = downloadLink.href;
}
// 2. 尝试获取图片 src
if (!url) {
const img = item.querySelector('img[src^="/d/"]');
if (img && img.src) {
url = img.src;
}
}
// 3. Fallback: Dataset
if (!url) {
const dsUrl = item.dataset.fileUrl;
if (dsUrl && dsUrl !== 'undefined') {
url = dsUrl;
if (url.startsWith('/')) {
url = window.location.origin + url;
}
}
}
// 4. Final Fallback
if (!url || url.includes('undefined')) {
const shortId = item.dataset.shortId;
const fileId = item.dataset.fileId;
const id = (shortId && shortId !== 'None' && shortId !== '') ? shortId : fileId;
url = window.location.origin + `/d/${id}`;
}
const name = item.dataset.filename;
if (format === 'markdown') return `![${name}](${url})`;
if (format === 'html') return `<img src="${url}" alt="${name}">`;
return url;
});
Utils.copy(links.join('\n'));
});
}
// Batch Delete
if (batchDeleteBtn) {
batchDeleteBtn.addEventListener('click', async () => {
const checked = document.querySelectorAll('.file-checkbox:checked');
if (checked.length === 0) return;
const confirmed = await Modal.confirm('批量删除', `确定要删除选中的 ${checked.length} 个文件吗?`);
if (!confirmed) return;
const fileIds = Array.from(checked).map(cb => cb.dataset.fileId);
fetch('/api/batch_delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: fileIds })
})
.then(res => res.json())
.then(data => {
if (data.deleted) {
data.deleted.forEach(item => {
const id = item.details?.file_id || item;
removeFileElement(id);
});
if (window.Toast) Toast.show(`已删除 ${data.deleted.length} 个文件`);
}
updateBatchControls();
});
});
}
// --- SSE & Realtime Updates ---
const fileListContainer = document.getElementById('file-list-disk');
if (fileListContainer) {
let eventSource = null;
const connectSSE = () => {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/api/file-updates');
eventSource.onmessage = (event) => {
const msg = JSON.parse(event.data);
const action = msg && msg.action ? msg.action : 'add';
if (action === 'delete') {
removeFileElement(msg.file_id);
updateBatchControls();
return;
}
addNewFileElement(msg);
};
eventSource.onerror = () => {
try { eventSource.close(); } catch (_) {}
setTimeout(connectSSE, 5000);
};
};
connectSSE();
}
function formatDateValue(value) {
if (!value) return '';
const d = new Date(value);
if (!isNaN(d.getTime())) return d.toISOString().split('T')[0];
const s = String(value);
return s.split(' ')[0].split('T')[0];
}
function addNewFileElement(file) {
const isGridView = document.querySelector('.image-grid') !== null;
const container = document.getElementById('file-list-disk');
// Remove empty state if exists
const emptyState = container.querySelector('div[style*="text-align: center"]');
if (emptyState) emptyState.remove();
const formattedSize = (file.filesize / (1024 * 1024)).toFixed(2) + " MB";
const formattedDate = formatDateValue(file.upload_date);
const safeId = file.file_id.replace(':', '-');
// URL construction: Always use /d/{file_id} (short_id preferred)
// 回滚:只使用 /d/{id} 格式,不再拼接文件名或 slug
let fileUrl = `/d/${file.short_id || file.file_id}`;
let html = '';
if (isGridView) {
html = `
<div class="file-item" style="border: 1px solid var(--border-color); border-radius: var(--radius-md); overflow: hidden; background: var(--bg-body);" id="file-item-${safeId}" data-file-id="${file.file_id}" data-file-url="${fileUrl}" data-filename="${file.filename}" data-short-id="${file.short_id || ''}">
<div style="position: relative; aspect-ratio: 16/9; background: #000;">
<img src="${fileUrl}" loading="lazy" style="width: 100%; height: 100%; object-fit: contain;" alt="${file.filename}">
<div style="position: absolute; top: 8px; left: 8px;">
<input type="checkbox" class="file-checkbox" data-file-id="${file.file_id}" style="width: 16px; height: 16px; cursor: pointer;">
</div>
</div>
<div style="padding: 12px;">
<div class="text-sm font-medium" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px;" title="${file.filename}">${file.filename}</div>
<div class="text-sm text-muted" style="margin-bottom: 12px;">${formattedSize}</div>
<div style="display: flex; gap: 8px;">
<button class="btn btn-secondary btn-sm copy-link-btn" style="flex: 1; height: 32px;">复制</button>
<button class="btn btn-secondary btn-sm delete" style="height: 32px; color: var(--danger-color);" onclick="deleteFile('${file.file_id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</div>
</div>`;
} else {
html = `
<tr class="file-item" style="border-bottom: 1px solid var(--border-color);" id="file-item-${safeId}" data-file-id="${file.file_id}" data-file-url="${fileUrl}" data-filename="${file.filename}" data-short-id="${file.short_id || ''}">
<td style="padding: 12px 16px;"><input type="checkbox" class="file-checkbox" data-file-id="${file.file_id}"></td>
<td style="padding: 12px 16px;">
<div style="display: flex; align-items: center; gap: 8px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--primary-color);"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
<span class="text-sm font-medium" style="color: var(--text-primary);">${file.filename}</span>
</div>
</td>
<td style="padding: 12px 16px;" class="text-sm text-muted">${formattedSize}</td>
<td style="padding: 12px 16px;" class="text-sm text-muted">${formattedDate}</td>
<td style="padding: 12px 16px; text-align: right;">
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<a href="${fileUrl}" class="btn btn-ghost" style="padding: 4px 8px; height: 28px;" title="下载">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
</a>
<button class="btn btn-ghost copy-link-btn" style="padding: 4px 8px; height: 28px;" title="复制链接">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button class="btn btn-ghost delete" style="padding: 4px 8px; height: 28px; color: var(--danger-color);" onclick="deleteFile('${file.file_id}')" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</td>
</tr>`;
}
container.insertAdjacentHTML('afterbegin', html);
}
// --- Global Helpers ---
window.deleteFile = async (fileId) => {
const confirmed = await Modal.confirm('删除文件', '确定要删除此文件吗?');
if (!confirmed) return;
fetch(`/api/files/${fileId}`, { method: 'DELETE' })
.then(async (res) => {
let data = null;
try { data = await res.json(); } catch (e) {}
return { ok: res.ok, data };
})
.then(({ ok, data }) => {
if (ok && data && data.status === 'ok') {
removeFileElement(fileId);
if (window.Toast) Toast.show('文件已删除');
updateBatchControls();
} else {
const msg = data?.detail?.message || data?.message || '删除失败';
if (window.Toast) Toast.show(msg, 'error');
}
});
};
function removeFileElement(fileId) {
const el = document.getElementById(`file-item-${fileId.replace(':', '-')}`);
if (el) el.remove();
// Check if empty
const container = document.getElementById('file-list-disk');
if (container && container.children.length === 0) {
// Re-render empty state logic if needed, or let user refresh
// Simple text fallback
const isGridView = document.querySelector('.image-grid') !== null;
if (isGridView) {
container.innerHTML = `
<div style="grid-column: 1/-1; padding: 40px; text-align: center; color: var(--text-tertiary);">
<p>暂无图片</p>
</div>`;
} else {
container.innerHTML = `
<tr>
<td colspan="5" style="padding: 48px; text-align: center;">
<div class="text-muted">暂无文件</div>
</td>
</tr>`;
}
}
}
});