91148fe064
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI 2. 新增物理弹球相关调试与单元测试脚本 3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示 4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
1402 lines
48 KiB
JavaScript
1402 lines
48 KiB
JavaScript
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()
|