feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本

1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目
2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理
3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景
4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
yangxiangyuan
2026-08-07 17:49:24 +08:00
parent 0f8fe2c2e2
commit 4492b4106a
22 changed files with 6935 additions and 34 deletions
+123 -32
View File
@@ -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