const apiBase = '/api/private_clipboard' const $ = id => document.getElementById(id) const state = { items: [], folders: [], filter: { type: 'all', keyword: '', view: 'all', folderCode: 'all' }, favoriteDraft: { id: null, note: '' }, unfavoriteDraft: { id: null, folderName: '' }, viewer: { images: [], index: 0 }, entryMode: 'quick', pagination: { page: 1, pageSize: '50', total: 0, totalPages: 1, isAll: false, hasPrev: false, hasNext: false } } const isQuickEntryMode = () => state.entryMode === 'quick' const MAX_UPLOAD_BYTES = 200 * 1024 * 1024 const normalizeError = (error) => { const text = String(error && error.message ? error.message : error || '').trim() if (!text) return 'unknown_error' return text } const describeUploadError = (error, file) => { const code = normalizeError(error) const name = String(file && file.name || '').trim() const prefix = name ? `文件“${name}”` : '文件' if (code === 'unauthorized') return '上传失败:登录状态已失效,请重新登录' if (code === 'empty_file') return `${prefix}为空,未能上传` if (code === 'file_too_large' || code === 'upload_failed_413' || code === 'unexpected_response_413') { return `${prefix}过大,当前上限为 200MB` } if (code === 'upload_network_error') return '上传失败:网络中断或服务器无响应' if (code === 'upload_aborted') return '上传已中断,请重新上传' if (code === 'upload_failed_400') return '上传失败:请求无效,请重新选择文件后再试' if (code === 'upload_failed_401') return '上传失败:当前未登录或登录已过期' if (code === 'upload_failed_403') return '上传失败:当前没有权限执行此操作' if (code === 'upload_failed_404') return '上传失败:上传接口不存在或访问地址失效' if (code === 'upload_failed_415') return '上传失败:文件类型或请求格式不被服务器接受' if (code === 'upload_failed_500' || code === 'upload_failed') return '上传失败:服务器处理文件时出错' return `上传失败:${code}` } const folderDisplayName = (code) => { const folder = state.folders.find(item => item.code === code) return folder ? folder.name : '未命名目录' } const folderSortOrder = (code) => { const folder = state.folders.find(item => item.code === code) return Number(folder && folder.sort_order ? folder.sort_order : Number.MAX_SAFE_INTEGER) } const resetPage = () => { state.pagination.page = 1 } const clearUrlQuery = () => { try { if (!window.location.search) return const cleanUrl = `${window.location.pathname}${window.location.hash || ''}` window.history.replaceState({}, '', cleanUrl) } catch {} } const fmtDateTime = (ts) => { const date = new Date(ts || Date.now()) try { const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date) return s.replace(/\//g, '-').replace(/\u200E/g, '') } catch { const pad = n => String(n).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` } } const fmtSize = (bytes) => { const n = Number(bytes || 0) if (!n) return '0 B' const units = ['B', 'KB', 'MB', 'GB'] let size = n let idx = 0 while (size >= 1024 && idx < units.length - 1) { size /= 1024 idx += 1 } return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[idx]}` } const setStatus = (text, type) => { const el = $('pc-status') if (!el) return el.textContent = text || '' el.className = 'pc-status' + (type ? ` ${type}` : '') } // 鉴权失效时统一回到登录界面,避免“贴和传没有响应”的假死体验 const showAuthScreen = (message) => { const auth = $('pc-auth') const app = $('pc-app') if (app) app.style.display = 'none' if (auth) auth.style.display = '' const status = $('pc-auth-status') if (status && message) { status.textContent = message status.className = 'pc-auth-status' status.style.display = '' } } const notifyUnauthorized = () => { showAuthScreen('登录状态已失效,请重新登录;若仍无法使用,请从首页九宫格重新进入') } // byteInfo: { loaded, total } 当前文件的字节进度,用于大文件上传时实时更新进度条 const setProgress = (show, done, total, byteInfo) => { const box = $('pc-progress') const text = $('pc-progress-text') const percent = $('pc-progress-percent') const fill = $('pc-progress-fill') if (!box || !text || !percent || !fill) return if (!show) { box.className = 'pc-progress' fill.style.width = '0%' text.textContent = '' percent.textContent = '' return } const safeTotal = Math.max(1, Number(total || 0)) const safeDone = Math.min(safeTotal, Math.max(0, Number(done || 0))) let ratio = safeDone / safeTotal let label = `上传进度 ${safeDone}/${safeTotal}` if (byteInfo && Number(byteInfo.total) > 0) { const totalBytes = Number(byteInfo.total) const loaded = Math.min(totalBytes, Math.max(0, Number(byteInfo.loaded || 0))) ratio = (safeDone + loaded / totalBytes) / safeTotal label = `上传进度 ${safeDone}/${safeTotal} · ${fmtSize(loaded)} / ${fmtSize(totalBytes)}` } const pct = Math.min(100, Math.round(ratio * 100)) box.className = 'pc-progress show' text.textContent = label percent.textContent = `${pct}%` fill.style.width = `${pct}%` } const copyTextToClipboard = async (text) => { try { await navigator.clipboard.writeText(text) setStatus('已复制到剪贴板', 'ok') setTimeout(() => setStatus('', ''), 2000) } catch (e) { setStatus('复制失败', 'error') setTimeout(() => setStatus('', ''), 2000) } } const copyImageToClipboard = async (url) => { try { const response = await fetch(url) const blob = await response.blob() const item = new ClipboardItem({ [blob.type]: blob }) await navigator.clipboard.write([item]) setStatus('已复制图片到剪贴板', 'ok') setTimeout(() => setStatus('', ''), 2000) } catch (e) { setStatus('复制图片失败', 'error') setTimeout(() => setStatus('', ''), 2000) } } const detectMobile = () => { if (window.matchMedia) { return window.matchMedia('(max-width: 900px), (pointer: coarse)').matches } const ua = navigator.userAgent || '' return /Android|iPhone|iPad|iPod|Mobile/i.test(ua) } const createGroupId = () => -(Date.now() * 1000 + Math.floor(Math.random() * 1000)) const updateViewer = () => { const img = $('pc-viewer-img') const count = $('pc-viewer-count') const prev = $('pc-viewer-prev') const next = $('pc-viewer-next') const total = state.viewer.images.length const idx = Math.max(0, Math.min(state.viewer.index, total - 1)) state.viewer.index = idx const current = state.viewer.images[idx] if (img) { img.src = current ? current.url : '' img.alt = current ? current.name : '' } if (count) count.textContent = total ? `${idx + 1} / ${total}` : '' if (prev) prev.classList.toggle('disabled', idx <= 0) if (next) next.classList.toggle('disabled', idx >= total - 1) } const openViewerAt = (images, index) => { const viewer = $('pc-viewer') if (!viewer || !Array.isArray(images) || !images.length) return state.viewer.images = images state.viewer.index = Math.max(0, Math.min(index, images.length - 1)) viewer.classList.add('show') document.body.style.overflow = 'hidden' updateViewer() } const closeViewer = () => { const viewer = $('pc-viewer') if (viewer) viewer.classList.remove('show') document.body.style.overflow = '' } const stepViewer = (delta) => { const total = state.viewer.images.length if (!total) return const next = state.viewer.index + delta if (next < 0 || next >= total) return state.viewer.index = next updateViewer() } const apiJson = async (url, options) => { const res = await fetch(url, options || {}) if (res.status === 401) { notifyUnauthorized() throw new Error('unauthorized') } const contentType = res.headers.get('content-type') || '' if (!contentType.includes('application/json')) { throw new Error(`unexpected_response_${res.status}`) } const data = await res.json() if (!data || !data.ok) throw new Error((data && data.error) || 'request_failed') return data } const fetchFolders = async () => { const data = await apiJson(`${apiBase}/folders`, { cache: 'no-store' }) state.folders = Array.isArray(data.folders) ? data.folders : [] renderFolderFilters() renderFolderList() } const fetchList = async () => { const params = new URLSearchParams() params.set('view', state.filter.view) params.set('type', state.filter.type || 'all') params.set('keyword', state.filter.keyword || '') params.set('folder_code', state.filter.folderCode || 'all') params.set('page_size', String(state.pagination.pageSize || '50')) if (String(state.pagination.pageSize || '50') !== 'all') { params.set('page', String(state.pagination.page || 1)) } const data = await apiJson(`${apiBase}/list?${params.toString()}`, { cache: 'no-store' }) state.items = Array.isArray(data.items) ? data.items : [] const pagination = data && data.pagination ? data.pagination : {} state.pagination = { page: Number(pagination.page || 1), pageSize: pagination.page_size === 'all' ? 'all' : String(pagination.page_size || state.pagination.pageSize || '50'), total: Number(pagination.total || 0), totalPages: Number(pagination.total_pages || 1), isAll: !!pagination.is_all, hasPrev: !!pagination.has_prev, hasNext: !!pagination.has_next } renderList() renderPagination() } const refreshCurrentView = async ({ successMessage = '', includeFolders = false } = {}) => { if (successMessage) setStatus(`${successMessage},正在刷新...`, 'ok') if (includeFolders) await Promise.all([fetchFolders(), fetchList()]) else await fetchList() if (successMessage) setStatus(successMessage, 'ok') } const renderFolderFilters = () => { const wrap = $('pc-favorite-filter') const select = $('pc-folder-select') const favoriteSelect = $('pc-favorite-folder-select') const viewTitle = $('pc-view-title') if (viewTitle) viewTitle.textContent = state.filter.view === 'favorites' ? '我的收藏' : '全部内容' if (wrap) wrap.style.display = state.filter.view === 'favorites' ? '' : 'none' const fillSelect = (target, withAll) => { if (!target) return const old = String(target.value || '') target.innerHTML = '' if (withAll) { const opt = document.createElement('option') opt.value = 'all' opt.textContent = '全部目录' target.appendChild(opt) } state.folders.forEach(folder => { const opt = document.createElement('option') opt.value = folder.code opt.textContent = `${folder.name} (${Number(folder.favorite_count || 0)})` target.appendChild(opt) }) if (withAll) { target.value = state.folders.some(f => f.code === state.filter.folderCode) ? state.filter.folderCode : 'all' state.filter.folderCode = String(target.value || 'all') } else if (state.folders.length) { target.value = state.folders.some(f => f.code === old) ? old : state.folders[0].code } } fillSelect(select, true) fillSelect(favoriteSelect, false) } const renderFolderList = () => { const list = $('pc-folder-list') if (!list) return list.innerHTML = '' if (!state.folders.length) { const empty = document.createElement('div') empty.className = 'pc-folder-empty' empty.textContent = '暂无目录,请先新增目录' list.appendChild(empty) return } state.folders.forEach(folder => { const row = document.createElement('div') row.className = 'pc-folder-row' const nameInput = document.createElement('input') nameInput.value = folder.name || '' nameInput.placeholder = '目录名称' const sortInput = document.createElement('input') sortInput.type = 'number' sortInput.min = '1' sortInput.step = '1' sortInput.value = String(Number(folder.sort_order || 1)) const count = document.createElement('div') count.className = 'pc-folder-count' count.textContent = `收藏 ${Number(folder.favorite_count || 0)}` const saveBtn = document.createElement('button') saveBtn.className = 'btn' saveBtn.type = 'button' saveBtn.textContent = '保存' saveBtn.onclick = async () => { try { const payload = { name: String(nameInput.value || '').trim(), sort_order: Number.parseInt(String(sortInput.value || ''), 10) } await apiJson(`${apiBase}/folders/${encodeURIComponent(folder.code)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) await Promise.all([fetchFolders(), fetchList()]) setStatus('目录已更新', 'ok') } catch (e) { setStatus(`目录更新失败: ${normalizeError(e)}`, 'error') } } const delBtn = document.createElement('button') delBtn.className = 'btn' delBtn.type = 'button' delBtn.textContent = '删除' delBtn.onclick = async () => { try { await apiJson(`${apiBase}/folders/${encodeURIComponent(folder.code)}`, { method: 'DELETE' }) await Promise.all([fetchFolders(), fetchList()]) setStatus('目录已删除', 'ok') } catch (e) { const code = normalizeError(e) if (code === 'folder_not_empty') setStatus('目录下还有收藏内容,不能删除。请先取消收藏或迁移。', 'error') else setStatus(`删除目录失败: ${code}`, 'error') } } row.appendChild(nameInput) row.appendChild(sortInput) row.appendChild(count) row.appendChild(saveBtn) row.appendChild(delBtn) list.appendChild(row) }) } const openFavoriteModal = (id) => { state.favoriteDraft.id = id state.favoriteDraft.note = '' renderFolderFilters() const noteInput = $('pc-favorite-note') if (noteInput) noteInput.value = '' const modal = $('pc-favorite-modal') if (modal) modal.classList.add('show') } const closeFavoriteModal = () => { state.favoriteDraft.id = null state.favoriteDraft.note = '' const modal = $('pc-favorite-modal') if (modal) modal.classList.remove('show') } const openUnfavoriteModal = (item) => { state.unfavoriteDraft.id = item && item.id ? item.id : null state.unfavoriteDraft.folderName = folderDisplayName(item && item.favorite_folder_code) const message = $('pc-unfavorite-message') if (message) { message.textContent = `这条内容当前保存在“${state.unfavoriteDraft.folderName}”里。取消收藏后,它会离开长期保护区,之后才能被删除。` } const modal = $('pc-unfavorite-modal') if (modal) modal.classList.add('show') } const uploadFiles = async (files, { uploadingText = '正在上传...', successMessage = '已保存到服务器' } = {}) => { const list = Array.from(files || []).filter(Boolean) if (!list.length) return setStatus(uploadingText, '') const groupId = list.length > 1 ? createGroupId() : null setProgress(true, 0, list.length) try { let done = 0 for (const file of list) { try { await uploadFile(file, groupId, (loaded, totalBytes) => { setProgress(true, done, list.length, { loaded, total: totalBytes }) }) } catch (error) { if (error && typeof error === 'object' && !error.file) error.file = file throw error } done += 1 setProgress(true, done, list.length) } await refreshCurrentView({ successMessage }) setProgress(false) } catch (error) { setProgress(false) throw error } } const closeUnfavoriteModal = () => { state.unfavoriteDraft.id = null state.unfavoriteDraft.folderName = '' const modal = $('pc-unfavorite-modal') if (modal) modal.classList.remove('show') } const openFolderModal = () => { const modal = $('pc-folder-modal') if (modal) modal.classList.add('show') } const closeFolderModal = () => { const modal = $('pc-folder-modal') if (modal) modal.classList.remove('show') } const favoriteItem = async (id, folderCode) => { await apiJson(`${apiBase}/favorite`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, folder_code: folderCode, favorite_note: state.favoriteDraft.note || '' }) }) await Promise.all([fetchFolders(), fetchList()]) } const unfavoriteItem = async (id) => { await apiJson(`${apiBase}/unfavorite`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }) await Promise.all([fetchFolders(), fetchList()]) } const buildItemCard = (item) => { const card = document.createElement('div') card.className = 'pc-item' const header = document.createElement('div') header.className = 'pc-item-header' const meta = document.createElement('div') meta.className = 'pc-item-meta' const type = document.createElement('span') type.className = 'pc-item-type' type.textContent = item.type === 'image' ? '图片' : item.type === 'file' ? '文件' : item.type === 'mixed' ? '组合' : '文字' const time = document.createElement('span') time.textContent = fmtDateTime(item.created_at) meta.appendChild(type) meta.appendChild(time) if (item.is_favorite) { const tag = document.createElement('span') tag.className = 'pc-favorite-tag' tag.textContent = `收藏于: ${folderDisplayName(item.favorite_folder_code)}` meta.appendChild(tag) } const actions = document.createElement('div') actions.className = 'pc-item-actions' if (item.text_content) { const copyBtn = document.createElement('button') copyBtn.className = 'btn' copyBtn.type = 'button' copyBtn.textContent = '复制文字' copyBtn.addEventListener('click', () => copyTextToClipboard(item.text_content || '')) actions.appendChild(copyBtn) } if (item.is_favorite) { const unfavBtn = document.createElement('button') unfavBtn.className = 'btn' unfavBtn.type = 'button' unfavBtn.textContent = '取消收藏' unfavBtn.onclick = () => openUnfavoriteModal(item) actions.appendChild(unfavBtn) } else { const favBtn = document.createElement('button') favBtn.className = 'btn' favBtn.type = 'button' favBtn.textContent = '收藏' favBtn.onclick = () => { if (!state.folders.length) { openFolderModal() setStatus('请先创建目录,再进行收藏', 'error') return } openFavoriteModal(item.id) } actions.appendChild(favBtn) } const delBtn = document.createElement('button') delBtn.className = 'btn' delBtn.type = 'button' delBtn.textContent = '删除' if (item.is_favorite) { delBtn.disabled = true delBtn.title = '请先取消收藏才能删除' delBtn.classList.add('disabled') delBtn.addEventListener('click', () => setStatus('请先取消收藏,再删除内容', 'error')) } else { delBtn.addEventListener('click', async () => { try { await deleteItem(item.id) await fetchFolders() } catch (e) { const code = normalizeError(e) if (code === 'item_favorited') setStatus('请先取消收藏,再删除内容', 'error') else setStatus(`删除失败: ${code}`, 'error') } }) } actions.appendChild(delBtn) header.appendChild(meta) header.appendChild(actions) card.appendChild(header) if (item.is_favorite && item.favorite_note) { const note = document.createElement('div') note.className = 'pc-favorite-note' note.textContent = item.favorite_note card.appendChild(note) } if (item.text_content) { const text = document.createElement('div') text.className = 'pc-item-text' text.textContent = item.text_content || '' card.appendChild(text) } if (item.items && item.items.length) { const items = Array.isArray(item.items) ? item.items : [] const images = items.filter(i => i.type === 'image') const files = items.filter(i => i.type === 'file') if (images.length) { const grid = document.createElement('div') grid.className = 'pc-image-grid' const viewImages = images.map(i => ({ url: i.file_url, name: i.file_name || '图片' })) images.forEach((imgItem, idx) => { const wrap = document.createElement('div') wrap.className = 'pc-image-thumb' const img = document.createElement('img') img.src = imgItem.file_url img.alt = imgItem.file_name || 'image' wrap.appendChild(img) wrap.addEventListener('click', () => openViewerAt(viewImages, idx)) grid.appendChild(wrap) }) card.appendChild(grid) } if (files.length) { files.forEach(fileItem => { const row = document.createElement('div') row.className = 'pc-file-row' const name = document.createElement('div') name.className = 'pc-file-name' name.textContent = fileItem.file_name || '文件' const metaText = document.createElement('div') metaText.className = 'pc-file-meta' metaText.textContent = fmtSize(fileItem.file_size) const copyUrlBtn = document.createElement('button') copyUrlBtn.className = 'btn' copyUrlBtn.textContent = '复制URL' copyUrlBtn.onclick = () => { const url = new URL(fileItem.file_url, window.location.origin).href copyTextToClipboard(url) } const downloadBtn = document.createElement('button') downloadBtn.className = 'btn' downloadBtn.textContent = '下载' downloadBtn.addEventListener('click', async () => { try { const res = await fetch(fileItem.file_url) if (!res.ok) throw new Error(`HTTP ${res.status}`) const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = fileItem.file_name || 'file' document.body.appendChild(a) a.click() document.body.removeChild(a) setTimeout(() => URL.revokeObjectURL(url), 5000) } catch (e) { setStatus(`下载失败: ${String(e.message || e)}`, 'error') } }) row.appendChild(name) row.appendChild(metaText) row.appendChild(copyUrlBtn) row.appendChild(downloadBtn) card.appendChild(row) }) } } return card } const renderFavoriteGroups = (list, items) => { const groups = new Map() items.forEach(item => { const code = String(item.favorite_folder_code || 'uncategorized') if (!groups.has(code)) groups.set(code, []) groups.get(code).push(item) }) const orderedFolders = Array.from(groups.keys()).sort((a, b) => { const diff = folderSortOrder(a) - folderSortOrder(b) if (diff !== 0) return diff return folderDisplayName(a).localeCompare(folderDisplayName(b), 'zh-CN') }) orderedFolders.forEach(code => { const section = document.createElement('section') section.className = 'pc-folder-group' const title = document.createElement('div') title.className = 'pc-folder-group-title' title.textContent = `${folderDisplayName(code)} (${groups.get(code).length})` section.appendChild(title) groups.get(code) .slice() .sort((a, b) => Number(b.favorited_at || b.created_at || 0) - Number(a.favorited_at || a.created_at || 0)) .forEach(item => section.appendChild(buildItemCard(item))) list.appendChild(section) }) } const renderPagination = () => { const containers = ['pc-pagination-top', 'pc-pagination-bottom'].map($).filter(Boolean) containers.forEach(container => { container.innerHTML = '' container.classList.toggle('show', state.pagination.total > 0) if (state.pagination.total <= 0) return const left = document.createElement('div') left.className = 'pc-pagination-left' const label = document.createElement('label') label.className = 'pc-pagination-label' label.textContent = '每页' const select = document.createElement('select') select.className = 'pc-pagination-select' ;['50', '100', '500', 'all'].forEach(value => { const opt = document.createElement('option') opt.value = value opt.textContent = value === 'all' ? '全部' : value if (String(state.pagination.pageSize) === value) opt.selected = true select.appendChild(opt) }) select.addEventListener('change', async () => { state.pagination.pageSize = String(select.value || '50') resetPage() await fetchList() }) left.appendChild(label) left.appendChild(select) const summary = document.createElement('div') summary.className = 'pc-pagination-summary' summary.textContent = `共 ${state.pagination.total} 条` left.appendChild(summary) container.appendChild(left) if (!state.pagination.isAll) { const right = document.createElement('div') right.className = 'pc-pagination-right' const prev = document.createElement('button') prev.className = 'btn' prev.type = 'button' prev.textContent = '上一页' prev.disabled = !state.pagination.hasPrev prev.addEventListener('click', async () => { if (!state.pagination.hasPrev) return state.pagination.page = Math.max(1, Number(state.pagination.page || 1) - 1) await fetchList() }) const info = document.createElement('div') info.className = 'pc-pagination-info' info.textContent = `${state.pagination.page} / ${state.pagination.totalPages}` const next = document.createElement('button') next.className = 'btn' next.type = 'button' next.textContent = '下一页' next.disabled = !state.pagination.hasNext next.addEventListener('click', async () => { if (!state.pagination.hasNext) return state.pagination.page = Number(state.pagination.page || 1) + 1 await fetchList() }) right.appendChild(prev) right.appendChild(info) right.appendChild(next) container.appendChild(right) } }) } const renderList = () => { const list = $('pc-items') const empty = $('pc-empty') const count = $('pc-count') if (!list || !empty) return list.innerHTML = '' const items = Array.isArray(state.items) ? state.items : [] if (count) count.textContent = state.pagination.total ? `(${state.pagination.total})` : '' if (!items.length) { empty.style.display = '' renderPagination() return } empty.style.display = 'none' if (state.filter.view === 'favorites') renderFavoriteGroups(list, items) else items.forEach(item => list.appendChild(buildItemCard(item))) renderPagination() } const saveText = async (text) => { const res = await fetch(`${apiBase}/text`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }) if (res.status === 401) { notifyUnauthorized() throw new Error('unauthorized') } const data = await res.json() if (!data || !data.ok) throw new Error((data && data.error) || 'save_failed') } const resolveFileName = (file) => { const name = String(file && file.name || '').trim() if (name) return name const ext = file && file.type ? file.type.split('/')[1] : '' const suffix = ext ? `.${ext}` : '' return `clipboard-${Date.now()}${suffix}` } // 使用 XHR 上传,利用 upload.onprogress 获取字节级进度(fetch 不支持上传进度事件) const uploadFile = (file, groupId, onProgress) => new Promise((resolve, reject) => { if (!file) { resolve(); return } if (Number(file.size || 0) > MAX_UPLOAD_BYTES) { reject(new Error('file_too_large')) return } const name = resolveFileName(file) const groupPart = Number.isFinite(groupId) ? `&group_id=${encodeURIComponent(String(groupId))}` : '' const url = `${apiBase}/upload?filename=${encodeURIComponent(name)}&mime=${encodeURIComponent(file.type || 'application/octet-stream')}${groupPart}` const xhr = new XMLHttpRequest() xhr.open('POST', url) xhr.setRequestHeader('Content-Type', 'application/octet-stream') if (xhr.upload && typeof onProgress === 'function') { xhr.upload.addEventListener('progress', (e) => { if (e && e.lengthComputable) onProgress(e.loaded, e.total) }) } xhr.onload = () => { let data = null try { data = JSON.parse(xhr.responseText || '') } catch {} if (xhr.status === 401) { notifyUnauthorized() reject(new Error('unauthorized')) return } if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) { resolve(data) return } reject(new Error((data && data.error) || `upload_failed_${xhr.status}`)) } xhr.onerror = () => reject(new Error('upload_network_error')) xhr.onabort = () => reject(new Error('upload_aborted')) xhr.send(file) }) const deleteItem = async (id) => { if (!id) return const res = await fetch(`${apiBase}/${id}`, { method: 'DELETE' }) if (res.status === 401) { notifyUnauthorized() throw new Error('unauthorized') } const data = await res.json() if (!data || !data.ok) throw new Error((data && data.error) || 'delete_failed') await fetchList() } // 某些粘贴来源下 getAsString 回调可能永不触发,加超时保护避免“正在保存...”假死 const getStringFromItem = (item, timeoutMs) => new Promise((resolve) => { let settled = false const timer = setTimeout(() => finish(null), timeoutMs || 1500) const finish = (value) => { if (settled) return settled = true clearTimeout(timer) resolve(value) } try { item.getAsString(text => finish(text)) } catch { finish(null) } }) const handlePaste = async (event) => { if (!isQuickEntryMode()) return const data = event.clipboardData if (!data) return const items = Array.from(data.items || []) if (!items.length) return event.preventDefault() setStatus('正在保存...', '') setProgress(false) try { const files = items.filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(Boolean) const strings = items.filter(i => i.kind === 'string') let stringSaveError = null if (files.length) { const groupId = files.length > 1 ? createGroupId() : null setProgress(true, 0, files.length) let done = 0 for (const f of files) { await uploadFile(f, groupId, (loaded, totalBytes) => { setProgress(true, done, files.length, { loaded, total: totalBytes }) }) done += 1 setProgress(true, done, files.length) } } for (const s of strings) { try { const text = await getStringFromItem(s, 1500) const v = String(text || '').trim() if (v) await saveText(v) } catch (err) { stringSaveError = err } } if (stringSaveError) throw stringSaveError await refreshCurrentView({ successMessage: '已保存到服务器' }) } catch (e) { const files = items.filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(Boolean) setStatus(files.length ? describeUploadError(e, e && e.file ? e.file : files[0]) : `保存失败:${normalizeError(e)}`, 'error') } finally { setProgress(false) } } const handleDrop = async (event) => { if (!isQuickEntryMode()) return event.preventDefault() const zone = $('paste-zone') if (zone) zone.classList.remove('dragover') const files = Array.from(event.dataTransfer && event.dataTransfer.files || []) if (!files.length) return try { await uploadFiles(files) } catch (e) { setStatus(describeUploadError(e, e && e.file ? e.file : files[0]), 'error') } } const bindDragEvents = () => { const zone = $('paste-zone') if (!zone) return zone.addEventListener('dragover', (event) => { event.preventDefault() zone.classList.add('dragover') }) zone.addEventListener('dragleave', (event) => { if (event.target === zone) zone.classList.remove('dragover') }) zone.addEventListener('drop', handleDrop) } const bindPasteInput = () => { const input = $('pc-paste-input') const btn = $('pc-paste-btn') if (!input) return const focusInput = () => { input.value = '' input.focus({ preventScroll: true }) input.select() } if (btn) btn.addEventListener('click', focusInput) // 阻止冒泡到 document 级 paste 监听,避免同一次粘贴被处理两遍 input.addEventListener('paste', (event) => { event.stopPropagation() handlePaste(event) }) const handleInput = async () => { if (!isQuickEntryMode()) return const value = String(input.value || '').trim() if (!value) return input.value = '' setStatus('正在保存...', '') try { await saveText(value) await refreshCurrentView({ successMessage: '已保存到服务器' }) } catch (e) { setStatus('保存失败', 'error') } } input.addEventListener('input', handleInput) input.addEventListener('change', handleInput) } const bindDesktopEntryTabs = () => { const quickBtn = $('pc-entry-quick') const classicBtn = $('pc-entry-classic') const quickPanel = $('pc-entry-panel-quick') const classicPanel = $('pc-entry-panel-classic') const setMode = (mode) => { state.entryMode = mode === 'classic' ? 'classic' : 'quick' const quickActive = isQuickEntryMode() if (quickBtn) { quickBtn.classList.toggle('active', quickActive) quickBtn.setAttribute('aria-selected', quickActive ? 'true' : 'false') } if (classicBtn) { classicBtn.classList.toggle('active', !quickActive) classicBtn.setAttribute('aria-selected', !quickActive ? 'true' : 'false') } if (quickPanel) { quickPanel.classList.toggle('active', quickActive) quickPanel.setAttribute('aria-hidden', quickActive ? 'false' : 'true') } if (classicPanel) { classicPanel.classList.toggle('active', !quickActive) classicPanel.setAttribute('aria-hidden', !quickActive ? 'false' : 'true') } } if (quickBtn) quickBtn.addEventListener('click', () => setMode('quick')) if (classicBtn) classicBtn.addEventListener('click', () => setMode('classic')) setMode('quick') } const bindDesktopClassicInputs = () => { const classicText = $('pc-classic-text') const classicTextSave = $('pc-classic-text-save') if (classicText && classicTextSave) { classicTextSave.addEventListener('click', async () => { const value = String(classicText.value || '').trim() if (!value) { setStatus('请输入文字', 'error') return } setStatus('正在保存...', '') try { await saveText(value) classicText.value = '' await refreshCurrentView({ successMessage: '已保存到服务器' }) } catch (error) { setStatus('保存失败', 'error') } }) } const bindUpload = (inputId, buttonId, emptyText, uploadingText) => { const input = $(inputId) const button = $(buttonId) if (!input || !button) return button.addEventListener('click', async () => { const files = Array.from(input.files || []) if (!files.length) { setStatus(emptyText, 'error') return } try { await uploadFiles(files, { uploadingText }) input.value = '' } catch (error) { setStatus(describeUploadError(error, error && error.file ? error.file : files[0]), 'error') } }) } bindUpload('pc-classic-image', 'pc-classic-image-save', '请选择图片', '正在上传图片...') bindUpload('pc-classic-file', 'pc-classic-file-save', '请选择文件', '正在上传文件...') } const bindMobileInputs = () => { const textInput = $('pc-mobile-text') const textSave = $('pc-mobile-text-save') if (textInput && textSave) { textSave.addEventListener('click', async () => { const value = String(textInput.value || '').trim() if (!value) { setStatus('请输入文字', 'error') return } setStatus('正在保存...', '') try { await saveText(value) textInput.value = '' await refreshCurrentView({ successMessage: '已保存到服务器' }) } catch (e) { setStatus('保存失败', 'error') } }) } const uploadSelected = (inputEl, emptyText) => { if (!inputEl) return return async () => { const files = Array.from(inputEl.files || []) if (!files.length) { setStatus(emptyText, 'error') return } try { await uploadFiles(files) inputEl.value = '' } catch (e) { setStatus(describeUploadError(e, e && e.file ? e.file : files[0]), 'error') } } } const imageInput = $('pc-mobile-image') const imageSave = $('pc-mobile-image-save') if (imageSave) imageSave.addEventListener('click', uploadSelected(imageInput, '请选择图片')) const fileInput = $('pc-mobile-file') const fileSave = $('pc-mobile-file-save') if (fileSave) fileSave.addEventListener('click', uploadSelected(fileInput, '请选择文件')) } const createFolder = async () => { const nameInput = $('pc-folder-name') const sortInput = $('pc-folder-sort') const name = String(nameInput && nameInput.value || '').trim() const sortOrder = Number.parseInt(String(sortInput && sortInput.value || ''), 10) if (!name) { setStatus('目录名称不能为空', 'error') return } await apiJson(`${apiBase}/folders`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, sort_order: Number.isFinite(sortOrder) && sortOrder > 0 ? sortOrder : undefined }) }) if (nameInput) nameInput.value = '' if (sortInput) sortInput.value = '' await Promise.all([fetchFolders(), fetchList()]) setStatus('目录已创建', 'ok') } const init = async () => { // ===== set_token 仅允许从 hash 读取,避免进入服务端日志 ===== try { const currentUrl = new URL(window.location.href) const hashRaw = String(currentUrl.hash || '').replace(/^#/, '') const hashParams = new URLSearchParams(hashRaw.startsWith('?') ? hashRaw.slice(1) : hashRaw) const setToken = hashParams.get('set_token') if (setToken) { localStorage.setItem('toolbox_creds_token', setToken) hashParams.delete('set_token') currentUrl.hash = hashParams.toString() ? `#${hashParams.toString()}` : '' window.history.replaceState(null, '', currentUrl.toString()) } } catch {} clearUrlQuery() // ===== 登录逻辑 ===== const $auth = $('pc-auth') const $app = $('pc-app') const $status = $('pc-auth-status') const $user = $('pc-login-user') const $pass = $('pc-login-pass') async function doLogin() { try { const username = $user.value.trim() const password = $pass.value const res = await fetch(apiBase + '/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), credentials: 'same-origin', cache: 'no-store' }) const data = await res.json() if (!data.ok) throw new Error(data.error || '登录失败') $auth.style.display = 'none' $app.style.display = '' initApp() } catch (e) { $status.textContent = String(e.message || e) $status.style.display = '' $status.className = 'pc-auth-status' } } async function doLogout() { try { await fetch(apiBase + '/auth/logout', { method: 'POST', credentials: 'same-origin' }) } catch {} $app.style.display = 'none' $auth.style.display = '' $pass.value = '' $status.textContent = '已注销' $status.className = 'pc-auth-status ok' $status.style.display = '' } // 自动填充 async function tryAutoFill() { try { const token = localStorage.getItem('toolbox_creds_token') || '' if (!token) return const res = await fetch(apiBase + '/auth/local_creds', { headers: { 'X-Local-Creds-Token': token }, credentials: 'same-origin', cache: 'no-store' }) const data = await res.json().catch(() => null) if (data && data.ok && data.creds) { if (data.creds.username) $user.value = data.creds.username if (data.creds.password) $pass.value = data.creds.password } } catch {} } $('pc-btn-login').onclick = doLogin $('pc-btn-logout').onclick = doLogout $user.removeAttribute('readonly') $pass.removeAttribute('readonly') $pass.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin() }) // 先检查是否已登录 try { const res = await fetch(apiBase + '/auth/me', { credentials: 'same-origin', cache: 'no-store' }) if (res.status === 401) { // 外层授权或登录态失效:回到登录界面并尝试自动填充 showAuthScreen('访问授权或登录已失效,请重新登录') await tryAutoFill() return } const data = await res.json() if (data && data.ok) { $auth.style.display = 'none' $app.style.display = '' initApp() return } } catch {} // 未登录,尝试自动填充 await tryAutoFill() } let appReady = false const initApp = async () => { // 登录态失效后重新登录时,避免重复绑定事件,只刷新数据 if (appReady) { try { await Promise.all([fetchFolders(), fetchList()]) } catch {} return } appReady = true clearUrlQuery() const isMobile = detectMobile() let keywordTimer = null const refreshBtn = $('pc-refresh') if (refreshBtn) { refreshBtn.addEventListener('click', async () => { await Promise.all([fetchFolders(), fetchList()]) setStatus('已刷新', 'ok') }) } const viewAllBtn = $('pc-view-all') const viewFavoritesBtn = $('pc-view-favorites') const folderFilterSelect = $('pc-folder-select') const setView = async (view) => { state.filter.view = view === 'favorites' ? 'favorites' : 'all' state.filter.folderCode = 'all' resetPage() if (viewAllBtn) viewAllBtn.classList.toggle('active', state.filter.view === 'all') if (viewFavoritesBtn) viewFavoritesBtn.classList.toggle('active', state.filter.view === 'favorites') renderFolderFilters() await fetchList() } if (viewAllBtn) viewAllBtn.addEventListener('click', () => setView('all')) if (viewFavoritesBtn) viewFavoritesBtn.addEventListener('click', () => setView('favorites')) if (folderFilterSelect) { folderFilterSelect.addEventListener('change', async () => { state.filter.folderCode = String(folderFilterSelect.value || 'all') resetPage() await fetchList() }) } const filterInput = $('pc-filter-input') if (filterInput) { filterInput.addEventListener('input', (event) => { state.filter.keyword = String(event.target.value || '') resetPage() if (keywordTimer) window.clearTimeout(keywordTimer) keywordTimer = window.setTimeout(() => { fetchList().catch((e) => setStatus(`查询失败: ${normalizeError(e)}`, 'error')) }, 180) }) } const clearBtn = $('pc-filter-clear') if (clearBtn) { clearBtn.addEventListener('click', async () => { state.filter.keyword = '' if (filterInput) filterInput.value = '' resetPage() await fetchList() }) } const typeButtons = Array.from(document.querySelectorAll('.pc-filter-btn')) typeButtons.forEach(btn => { btn.addEventListener('click', async () => { const type = String(btn.dataset.type || 'all') state.filter.type = type || 'all' resetPage() typeButtons.forEach(b => b.classList.toggle('active', b === btn)) await fetchList() }) }) if (typeButtons.length) typeButtons[0].classList.add('active') const manageFoldersBtn = $('pc-manage-folders') if (manageFoldersBtn) manageFoldersBtn.addEventListener('click', openFolderModal) const folderCreateBtn = $('pc-folder-create') if (folderCreateBtn) { folderCreateBtn.addEventListener('click', async () => { try { await createFolder() } catch (e) { setStatus(`目录创建失败: ${normalizeError(e)}`, 'error') } }) } const folderCloseBtn = $('pc-folder-close') if (folderCloseBtn) folderCloseBtn.addEventListener('click', closeFolderModal) const favoriteCloseBtn = $('pc-favorite-close') if (favoriteCloseBtn) favoriteCloseBtn.addEventListener('click', closeFavoriteModal) const unfavoriteCloseBtn = $('pc-unfavorite-close') if (unfavoriteCloseBtn) unfavoriteCloseBtn.addEventListener('click', closeUnfavoriteModal) const unfavoriteCancelBtn = $('pc-unfavorite-cancel') if (unfavoriteCancelBtn) unfavoriteCancelBtn.addEventListener('click', closeUnfavoriteModal) const favoriteGoManageBtn = $('pc-favorite-go-manage') if (favoriteGoManageBtn) { favoriteGoManageBtn.addEventListener('click', () => { closeFavoriteModal() openFolderModal() }) } const favoriteConfirmBtn = $('pc-favorite-confirm') if (favoriteConfirmBtn) { favoriteConfirmBtn.addEventListener('click', async () => { const id = state.favoriteDraft.id const select = $('pc-favorite-folder-select') const noteInput = $('pc-favorite-note') const folderCode = String(select && select.value || '').trim() state.favoriteDraft.note = String(noteInput && noteInput.value || '').trim() if (!id || !folderCode) { setStatus('请选择收藏目录', 'error') return } try { await favoriteItem(id, folderCode) closeFavoriteModal() setStatus(`已收藏到 ${folderDisplayName(folderCode)}`, 'ok') } catch (e) { setStatus(`收藏失败: ${normalizeError(e)}`, 'error') } }) } const unfavoriteConfirmBtn = $('pc-unfavorite-confirm') if (unfavoriteConfirmBtn) { unfavoriteConfirmBtn.addEventListener('click', async () => { const id = state.unfavoriteDraft.id if (!id) { closeUnfavoriteModal() return } try { await unfavoriteItem(id) closeUnfavoriteModal() setStatus('已取消收藏', 'ok') } catch (e) { setStatus(`取消收藏失败: ${normalizeError(e)}`, 'error') } }) } document.querySelectorAll('.pc-modal-backdrop').forEach(el => { el.addEventListener('click', () => { const closeType = String(el.getAttribute('data-close') || '') if (closeType === 'favorite') closeFavoriteModal() if (closeType === 'folder') closeFolderModal() if (closeType === 'unfavorite') closeUnfavoriteModal() }) }) if (!isMobile) { const zone = $('paste-zone') if (zone) zone.addEventListener('click', () => zone.focus()) bindDesktopEntryTabs() document.addEventListener('paste', handlePaste) bindDragEvents() bindPasteInput() bindDesktopClassicInputs() } else { bindMobileInputs() } const viewerClose = $('pc-viewer-close') if (viewerClose) viewerClose.addEventListener('click', closeViewer) const viewerPrev = $('pc-viewer-prev') if (viewerPrev) viewerPrev.addEventListener('click', () => stepViewer(-1)) const viewerNext = $('pc-viewer-next') if (viewerNext) viewerNext.addEventListener('click', () => stepViewer(1)) const viewerBackdrop = $('pc-viewer-backdrop') if (viewerBackdrop) viewerBackdrop.addEventListener('click', closeViewer) const viewerCopyUrl = $('pc-viewer-copy-url') if (viewerCopyUrl) { viewerCopyUrl.addEventListener('click', () => { const current = state.viewer.images[state.viewer.index] if (current) { const url = new URL(current.url, window.location.origin).href copyTextToClipboard(url) } }) } const viewerCopyImg = $('pc-viewer-copy-img') if (viewerCopyImg) { viewerCopyImg.addEventListener('click', () => { const current = state.viewer.images[state.viewer.index] if (current) { const url = new URL(current.url, window.location.origin).href copyImageToClipboard(url) } }) } document.addEventListener('keydown', (event) => { const viewer = $('pc-viewer') const favoriteModal = $('pc-favorite-modal') const folderModal = $('pc-folder-modal') const unfavoriteModal = $('pc-unfavorite-modal') if (event.key === 'Escape') { if (favoriteModal && favoriteModal.classList.contains('show')) closeFavoriteModal() if (folderModal && folderModal.classList.contains('show')) closeFolderModal() if (unfavoriteModal && unfavoriteModal.classList.contains('show')) closeUnfavoriteModal() if (viewer && viewer.classList.contains('show')) closeViewer() return } if (!viewer || !viewer.classList.contains('show')) return if (event.key === 'ArrowLeft') stepViewer(-1) if (event.key === 'ArrowRight') stepViewer(1) }) await fetchFolders() await fetchList() await setView('all') } init()