feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目 2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理 3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景 4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
@@ -77,7 +77,26 @@ const setStatus = (text, type) => {
|
||||
el.className = 'pc-status' + (type ? ` ${type}` : '')
|
||||
}
|
||||
|
||||
const setProgress = (show, done, total) => {
|
||||
// 鉴权失效时统一回到登录界面,避免“贴和传没有响应”的假死体验
|
||||
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')
|
||||
@@ -92,9 +111,17 @@ const setProgress = (show, done, total) => {
|
||||
}
|
||||
const safeTotal = Math.max(1, Number(total || 0))
|
||||
const safeDone = Math.min(safeTotal, Math.max(0, Number(done || 0)))
|
||||
const pct = Math.round((safeDone / safeTotal) * 100)
|
||||
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 = `上传进度 ${safeDone}/${safeTotal}`
|
||||
text.textContent = label
|
||||
percent.textContent = `${pct}%`
|
||||
fill.style.width = `${pct}%`
|
||||
}
|
||||
@@ -179,9 +206,12 @@ const stepViewer = (delta) => {
|
||||
|
||||
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')) {
|
||||
if (res.status === 401) throw new Error('unauthorized')
|
||||
throw new Error(`unexpected_response_${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
@@ -370,7 +400,9 @@ const uploadFiles = async (files, { uploadingText = '正在上传...', successMe
|
||||
try {
|
||||
let done = 0
|
||||
for (const file of list) {
|
||||
await uploadFile(file, groupId)
|
||||
await uploadFile(file, groupId, (loaded, totalBytes) => {
|
||||
setProgress(true, done, list.length, { loaded, total: totalBytes })
|
||||
})
|
||||
done += 1
|
||||
setProgress(true, done, list.length)
|
||||
}
|
||||
@@ -704,6 +736,10 @@ const saveText = async (text) => {
|
||||
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')
|
||||
}
|
||||
@@ -716,29 +752,68 @@ const resolveFileName = (file) => {
|
||||
return `clipboard-${Date.now()}${suffix}`
|
||||
}
|
||||
|
||||
const uploadFile = async (file, groupId) => {
|
||||
if (!file) return
|
||||
// 使用 XHR 上传,利用 upload.onprogress 获取字节级进度(fetch 不支持上传进度事件)
|
||||
const uploadFile = (file, groupId, onProgress) => new Promise((resolve, reject) => {
|
||||
if (!file) { resolve(); return }
|
||||
const name = resolveFileName(file)
|
||||
const buf = await file.arrayBuffer()
|
||||
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 res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: new Uint8Array(buf)
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data || !data.ok) throw new Error((data && data.error) || 'upload_failed')
|
||||
}
|
||||
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
|
||||
@@ -757,31 +832,28 @@ const handlePaste = async (event) => {
|
||||
setProgress(true, 0, files.length)
|
||||
let done = 0
|
||||
for (const f of files) {
|
||||
await uploadFile(f, groupId)
|
||||
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) {
|
||||
await new Promise((resolve) => {
|
||||
s.getAsString(async (text) => {
|
||||
try {
|
||||
const v = String(text || '').trim()
|
||||
if (v) await saveText(v)
|
||||
} catch (err) {
|
||||
stringSaveError = err
|
||||
} finally {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
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: '已保存到服务器' })
|
||||
setProgress(false)
|
||||
} catch (e) {
|
||||
setProgress(false)
|
||||
setStatus('保存失败', 'error')
|
||||
} finally {
|
||||
setProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,7 +894,11 @@ const bindPasteInput = () => {
|
||||
input.select()
|
||||
}
|
||||
if (btn) btn.addEventListener('click', focusInput)
|
||||
input.addEventListener('paste', handlePaste)
|
||||
// 阻止冒泡到 document 级 paste 监听,避免同一次粘贴被处理两遍
|
||||
input.addEventListener('paste', (event) => {
|
||||
event.stopPropagation()
|
||||
handlePaste(event)
|
||||
})
|
||||
const handleInput = async () => {
|
||||
if (!isQuickEntryMode()) return
|
||||
const value = String(input.value || '').trim()
|
||||
@@ -1065,6 +1141,12 @@ const init = async () => {
|
||||
// 先检查是否已登录
|
||||
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'
|
||||
@@ -1078,7 +1160,16 @@ const init = async () => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user