Files
Toolbox/public/tools/doc_cloud_keeper/doc_cloud_keeper.js
T
yangxiangyuan a6f763ffd8 feat: 重构认证流程,升级工具功能并优化后端配置
- 修复token泄露风险:改用X-Local-Creds-Token请求头传递token,从URL Hash读取set_token避免日志记录
- 重构全站认证逻辑:提取公用工具函数统一各服务的认证校验流程
- 升级投资账本工具:支持本地上传CSV和粘贴文本,移除硬编码路径,适配新参数格式
- 新增403/404/500标准错误响应文件,统一API错误返回格式
- 优化后端配置:关闭ETag,重构CORS中间件,新增全局错误捕获,限制敏感文件访问
- 优化web zen box的Service Worker,支持音频分片请求与断点续传
- 清理冗余前端代码,调整页面格式与权限控制
2026-08-01 21:50:05 +08:00

2107 lines
85 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const dck = (() => {
let me = null
let folders = []
let docs = []
let activeFolderId = null
let activeDocId = 0
let trashMode = false
let selectedIds = new Set()
let actionDepth = 0
let actionHasError = false
let actionTimer = null
let actionCountdown = 0
let actionLogger = null
let formResolver = null
let liveSyncTimer = null
let liveSyncBusy = false
let rememberedFsHandle = null
const docHandleCache = new Map()
let routeDocId = 0
let versionsTab = 'local'
let ossProbePicked = null
let localVersionCompareSelections = []
let localVersionCompareItems = []
let compareExcelSheetFilter = 'all'
const el = id => document.getElementById(id)
const txt = (id, v) => { const n = el(id); if (n) n.textContent = String(v == null ? '' : v) }
const show = (id, on) => { const n = el(id); if (n) n.style.display = on ? '' : 'none' }
const setHeaderActionsVisible = on => {
const n = el('dck-header-actions')
if (!n) return
n.style.display = on ? '' : 'none'
}
const esc = s => String(s || '').replace(/[&<>"]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]))
const fsDbName = 'doc_cloud_keeper_fs'
const fsStoreName = 'kv'
const fsStoreKey = 'last_handle'
const setOpenFolderButton = on => {
const btn = el('dck-action-open-folder')
if (!btn) return
btn.style.display = on ? '' : 'none'
}
const nowStr = () => {
const d = new Date()
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
const clearActionTimer = () => {
if (actionTimer) {
clearInterval(actionTimer)
actionTimer = null
}
}
const setActionStatus = (text, cls) => {
const n = el('dck-action-status')
if (!n) return
n.className = `dck-action-status${cls ? ` ${cls}` : ''}`
n.textContent = String(text || '')
}
const appendActionLog = (line, type) => {
const box = el('dck-action-logs')
if (!box) return
const div = document.createElement('div')
div.className = `dck-log-line${type ? ` ${type}` : ''}`
div.textContent = `[${nowStr()}] ${String(line || '')}`
box.appendChild(div)
box.scrollTop = box.scrollHeight
}
const actionErrorText = err => {
if (!err) return '未知错误'
if (typeof err === 'string') return err
const msg = String(err.message || err)
const stack = err && err.stack ? String(err.stack) : ''
return stack ? `${msg}\n${stack}` : msg
}
const copyText = async text => {
if (!navigator.clipboard || !navigator.clipboard.writeText) {
throw new Error('当前环境不支持剪贴板写入')
}
await navigator.clipboard.writeText(String(text || ''))
}
const openFsDb = () => new Promise((resolve, reject) => {
if (!window.indexedDB) return resolve(null)
const req = indexedDB.open(fsDbName, 1)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(fsStoreName)) db.createObjectStore(fsStoreName)
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error || new Error('open_db_failed'))
})
const loadRememberedHandle = async () => {
try {
const db = await openFsDb()
if (!db) return null
const handle = await new Promise((resolve, reject) => {
const tx = db.transaction(fsStoreName, 'readonly')
const store = tx.objectStore(fsStoreName)
const req = store.get(fsStoreKey)
req.onsuccess = () => resolve(req.result || null)
req.onerror = () => reject(req.error || new Error('read_handle_failed'))
})
db.close()
return handle || null
} catch {
return null
}
}
const saveRememberedHandle = async handle => {
try {
const db = await openFsDb()
if (!db) return
await new Promise((resolve, reject) => {
const tx = db.transaction(fsStoreName, 'readwrite')
const store = tx.objectStore(fsStoreName)
const req = store.put(handle, fsStoreKey)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error || new Error('write_handle_failed'))
})
db.close()
} catch {}
}
const docHandleStoreKey = docId => `doc_handle_${Number(docId || 0)}`
const saveDocHandle = async (docId, handle) => {
const id = Number(docId || 0)
if (!id || !handle) return
docHandleCache.set(id, handle)
try {
const db = await openFsDb()
if (!db) return
await new Promise((resolve, reject) => {
const tx = db.transaction(fsStoreName, 'readwrite')
const store = tx.objectStore(fsStoreName)
const req = store.put(handle, docHandleStoreKey(id))
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error || new Error('write_doc_handle_failed'))
})
db.close()
} catch {}
}
const loadDocHandle = async docId => {
const id = Number(docId || 0)
if (!id) return null
if (docHandleCache.has(id)) return docHandleCache.get(id)
try {
const db = await openFsDb()
if (!db) return null
const handle = await new Promise((resolve, reject) => {
const tx = db.transaction(fsStoreName, 'readonly')
const store = tx.objectStore(fsStoreName)
const req = store.get(docHandleStoreKey(id))
req.onsuccess = () => resolve(req.result || null)
req.onerror = () => reject(req.error || new Error('read_doc_handle_failed'))
})
db.close()
if (handle) docHandleCache.set(id, handle)
return handle || null
} catch {
return null
}
}
const openRememberedFolder = async () => {
if (!window.showDirectoryPicker) throw new Error('当前浏览器不支持“打开文件夹”')
const options = {}
if (rememberedFsHandle) options.startIn = rememberedFsHandle
const dirHandle = await window.showDirectoryPicker(options)
rememberedFsHandle = dirHandle
await saveRememberedHandle(dirHandle)
return dirHandle
}
const openActionModal = (title, subtitle) => {
clearActionTimer()
actionHasError = false
actionCountdown = 0
txt('dck-action-title', title || '处理中')
txt('dck-action-msg', subtitle || '')
setActionStatus('运行中', '')
const box = el('dck-action-logs')
if (box) box.innerHTML = ''
setOpenFolderButton(false)
show('dck-action-modal', true)
}
const closeActionModal = () => {
clearActionTimer()
show('dck-action-modal', false)
}
const finishActionSuccess = () => {
if (actionHasError) return
actionCountdown = 3
setActionStatus(`成功,${actionCountdown}s 后自动关闭`, 'ok')
clearActionTimer()
actionTimer = setInterval(() => {
actionCountdown -= 1
if (actionCountdown <= 0) {
clearActionTimer()
closeActionModal()
return
}
setActionStatus(`成功,${actionCountdown}s 后自动关闭`, 'ok')
}, 1000)
}
const failAction = err => {
actionHasError = true
setActionStatus('发生错误,已中断', 'err')
appendActionLog(actionErrorText(err), 'err')
}
const runAction = async (title, fn, subtitle) => {
const top = actionDepth === 0
if (top) openActionModal(title, subtitle || '')
actionDepth += 1
const prevLogger = actionLogger
const logger = line => appendActionLog(line)
actionLogger = logger
appendActionLog(`开始:${title}`)
try {
const out = await fn(logger)
appendActionLog(`完成:${title}`, 'ok')
return out
} catch (err) {
failAction(err)
throw err
} finally {
actionLogger = prevLogger
actionDepth -= 1
if (top && !actionHasError) finishActionSuccess()
}
}
const logLine = line => {
if (actionLogger) actionLogger(line)
}
const formatSize = bytes => {
const n = Number(bytes || 0)
if (!Number.isFinite(n) || n <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1 }
return `${v.toFixed(i === 0 ? 0 : 2)} ${units[i]}`
}
const toSafeFileLabel = text => String(text || '').replace(/[:]/g, ':').replace(/[|]/g, '|').replace(/[<>"/\\?*]/g, '_').trim()
const buildHistoryDownloadName = payload => {
const data = payload || {}
const filename = toSafeFileLabel(data.filename || 'document')
const sizeStr = toSafeFileLabel(formatSize(data.size_bytes || 0))
const recordTime = toSafeFileLabel(String(data.record_time || '').replace(/:/g, ':'))
const uploadTime = toSafeFileLabel(String(data.upload_time || '').replace(/:/g, ':'))
const sourceName = toSafeFileLabel(data.source_name || '下载文件')
const deletedPrefix = data.is_deleted ? '【物理删除】' : ''
const parts = [
`文件:${filename}`,
`大小:${sizeStr}`
]
if (recordTime) parts.push(`记录时间:${recordTime}`)
if (uploadTime) parts.push(`上传时间:${uploadTime}`)
parts.push(`来源:${sourceName}`)
return `${deletedPrefix}${parts.join(' | ')}`
}
const versionCompareKey = item => `${String((item && item.target_type) || '')}:${Number((item && item.id) || 0)}`
const syncVersionCompareButton = () => {
const btn = el('dck-versions-compare')
if (!btn) return
btn.disabled = localVersionCompareSelections.length !== 2
btn.textContent = localVersionCompareSelections.length === 2 ? '比较差异' : `比较差异 (${localVersionCompareSelections.length}/2)`
}
const isVersionSelected = item => localVersionCompareSelections.some(x => versionCompareKey(x) === versionCompareKey(item))
const toggleVersionCompareSelection = item => {
const key = versionCompareKey(item)
const existingIndex = localVersionCompareSelections.findIndex(x => versionCompareKey(x) === key)
if (existingIndex >= 0) {
localVersionCompareSelections.splice(existingIndex, 1)
syncVersionCompareButton()
return true
}
if (localVersionCompareSelections.length >= 2) return false
localVersionCompareSelections.push({
target_type: item.target_type,
id: Number(item.id || 0),
label: item.compare_label || item.filename || ''
})
syncVersionCompareButton()
return true
}
const countdownText = ms => {
if (ms == null || !Number.isFinite(ms)) return '-'
if (ms < 0) return `已逾期 ${countdownText(Math.abs(ms))}`
const total = Math.floor(ms / 1000)
const d = Math.floor(total / 86400)
const h = Math.floor((total % 86400) / 3600)
const m = Math.floor((total % 3600) / 60)
return `${d}天${h}时${m}分`
}
const parseBeijingDateTimeToMs = raw => {
const s = String(raw || '').trim()
if (!s) return NaN
const normalized = s.includes('T') ? s : s.replace(' ', 'T')
const withSeconds = normalized.length === 16 ? `${normalized}:00` : normalized
const iso = /[zZ]$|[+-]\d{2}:\d{2}$/.test(withSeconds) ? withSeconds : `${withSeconds}+08:00`
const ms = new Date(iso).getTime()
return Number.isFinite(ms) ? ms : NaN
}
const applyDeadlineState = rows => {
const list = Array.isArray(rows) ? rows : []
const now = Date.now()
const dayMs = 24 * 3600 * 1000
return list.map(r => {
if (String((r && r.status) || '').toLowerCase() === 'done') {
r.deadline_ms = null
r.deadline_state = 'normal'
return r
}
const due = parseBeijingDateTimeToMs(r && r.due_at)
if (!Number.isFinite(due)) {
r.deadline_ms = null
r.deadline_state = 'normal'
} else {
const diff = due - now
r.deadline_ms = diff
if (diff < 0) r.deadline_state = 'expired'
else if (diff <= dayMs) r.deadline_state = 'urgent'
else r.deadline_state = 'normal'
}
return r
})
}
const withNoCacheQuery = rawUrl => {
const sep = String(rawUrl || '').includes('?') ? '&' : '?'
return `${rawUrl}${sep}_ts=${Date.now()}`
}
const get = async url => {
const reqUrl = withNoCacheQuery(url)
logLine(`请求 GET ${reqUrl}`)
const r = await fetch(reqUrl, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-store, no-cache, max-age=0',
'Pragma': 'no-cache'
}
})
logLine(`响应状态: ${r.status}`)
const data = await r.json().catch(() => null)
if (!r.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${r.status}`)
return data
}
const post = async (url, body) => {
logLine(`请求 POST ${url}`)
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
logLine(`响应状态: ${r.status}`)
const data = await r.json().catch(() => null)
if (!r.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${r.status}`)
return data
}
const openFormModal = ({ title, message, fields, confirmText }) => {
txt('dck-form-title', title || '输入')
txt('dck-form-msg', message || '')
txt('dck-form-confirm', confirmText || '确认')
const box = el('dck-form-fields')
if (box) box.innerHTML = ''
;(fields || []).forEach(f => {
const row = document.createElement('div')
row.className = 'dck-form-row'
const label = document.createElement('label')
label.className = 'modal-label'
label.textContent = String(f.label || f.key || '')
row.appendChild(label)
let input = null
if (f.type === 'select') {
input = document.createElement('select')
;(f.options || []).forEach(opt => {
const o = document.createElement('option')
o.value = String(opt.value)
o.textContent = String(opt.label)
if (String(f.value) === String(opt.value)) o.selected = true
input.appendChild(o)
})
} else if (f.type === 'textarea') {
input = document.createElement('textarea')
input.rows = 3
input.value = f.value == null ? '' : String(f.value)
} else {
input = document.createElement('input')
input.type = f.type || 'text'
input.value = f.value == null ? '' : String(f.value)
}
input.className = 'modal-input'
if (f.placeholder) input.placeholder = String(f.placeholder)
input.dataset.key = String(f.key || '')
if (f.required) input.dataset.required = '1'
row.appendChild(input)
if (box) box.appendChild(row)
})
show('dck-form-modal', true)
}
const closeFormModal = () => {
show('dck-form-modal', false)
}
let previewDocId = 0
let previewContentText = ''
const openPreview = async doc => {
const id = Number(doc.id || 0)
if (!id) return
previewDocId = id
previewContentText = ''
txt('dck-preview-title', `快速查看:${doc.filename || '文档'}`)
const meta = el('dck-preview-meta')
const loading = el('dck-preview-loading')
const content = el('dck-preview-content')
const copyBtn = el('dck-preview-copy')
const dlBtn = el('dck-preview-download')
if (meta) meta.innerHTML = `类型:${esc(doc.ext || '-')} | 大小:${formatSize(doc.size_bytes || 0)} | 更新时间:${esc(doc.updated_at || '-')}`
if (loading) loading.style.display = ''
if (content) content.innerHTML = ''
if (copyBtn) copyBtn.style.display = 'none'
if (dlBtn) dlBtn.style.display = ''
show('dck-preview-modal', true)
try {
const ext = String(doc.ext || '').toLowerCase()
if (ext === 'pdf') {
if (loading) loading.style.display = 'none'
if (content) content.innerHTML = `<iframe src="/api/doc_cloud_keeper/docs/preview_pdf?id=${id}" class="dck-preview-iframe" title="PDF 预览"></iframe>`
if (copyBtn) copyBtn.style.display = 'none'
return
}
const resp = await fetch(withNoCacheQuery(`/api/doc_cloud_keeper/docs/preview?id=${id}`), {
cache: 'no-store',
headers: { 'Cache-Control': 'no-store, no-cache, max-age=0', 'Pragma': 'no-cache' }
})
const data = await resp.json().catch(() => null)
if (loading) loading.style.display = 'none'
if (!resp.ok || !data || !data.preview) {
if (content) content.innerHTML = `<div class="dck-preview-error">预览加载失败:${(data && data.error) || `HTTP ${resp.status}`}</div>`
return
}
const p = data.preview
if (p.mode === 'text') {
previewContentText = p.content || ''
if (copyBtn) copyBtn.style.display = ''
if (content) content.innerHTML = `<pre class="dck-preview-text">${esc(previewContentText)}</pre>`
return
}
if (p.mode === 'table') {
previewContentText = ''
if (copyBtn) copyBtn.style.display = 'none'
const t = p.table || {}
const headers = Array.isArray(t.headers) ? t.headers : []
const rows = Array.isArray(t.rows) ? t.rows : []
let html = `<div class="dck-preview-table-info">工作表:${esc(t.sheet_name || 'Sheet1')} | 总行数:${t.total_rows || 0}${t.truncated ? ' | 仅显示前 100 行' : ''}</div>`
html += '<div class="dck-preview-table-wrap"><table class="dck-preview-table"><thead><tr>'
headers.forEach(h => { html += `<th>${esc(h)}</th>` })
html += '</tr></thead><tbody>'
rows.forEach(row => {
html += '<tr>'
headers.forEach((_, i) => { html += `<td>${esc(row[i] == null ? '' : String(row[i]))}</td>` })
html += '</tr>'
})
html += '</tbody></table></div>'
if (content) content.innerHTML = html
return
}
if (p.mode === 'slides') {
previewContentText = ''
if (copyBtn) copyBtn.style.display = 'none'
const slides = Array.isArray(p.slides) ? p.slides : []
let html = ''
slides.forEach((s, i) => {
html += `<div class="dck-preview-slide"><div class="dck-preview-slide-num">第 ${i + 1} 页</div><div class="dck-preview-slide-title">${esc(s.title || '无标题')}</div>`
if (s.body) html += `<pre class="dck-preview-slide-body">${esc(s.body)}</pre>`
html += '</div>'
})
if (!html) html = '<div class="dck-preview-empty">未提取到幻灯片内容</div>'
if (content) content.innerHTML = html
return
}
if (p.mode === 'unsupported') {
if (content) content.innerHTML = `<div class="dck-preview-unsupported">${esc(p.message || '该文件类型暂不支持在线预览')}</div>`
if (copyBtn) copyBtn.style.display = 'none'
return
}
if (content) content.innerHTML = '<div class="dck-preview-unsupported">未知预览模式</div>'
} catch (e) {
if (loading) loading.style.display = 'none'
if (content) content.innerHTML = `<div class="dck-preview-error">预览异常:${esc(actionErrorText(e))}</div>`
}
}
const closePreview = () => {
show('dck-preview-modal', false)
previewDocId = 0
previewContentText = ''
}
const collectFormValues = () => {
const out = {}
const inputs = Array.from((el('dck-form-fields') || document.createElement('div')).querySelectorAll('[data-key]'))
inputs.forEach(n => {
out[n.dataset.key] = n.value
})
return out
}
const validateForm = () => {
const inputs = Array.from((el('dck-form-fields') || document.createElement('div')).querySelectorAll('[data-key]'))
for (const n of inputs) {
if (n.dataset.required === '1' && !String(n.value || '').trim()) {
throw new Error(`字段不能为空: ${n.dataset.key}`)
}
}
}
const askForm = opts => new Promise(resolve => {
formResolver = resolve
openFormModal(opts || {})
})
const askConfirm = async (title, message, confirmText) => {
const out = await askForm({ title, message, fields: [], confirmText: confirmText || '确认' })
return !!out
}
const currentQuery = () => {
const q = String((el('dck-search') && el('dck-search').value) || '').trim()
const priority = String((el('dck-filter-priority') && el('dck-filter-priority').value) || '')
const status = String((el('dck-filter-status') && el('dck-filter-status').value) || '')
const sort_by = String((el('dck-sort-by') && el('dck-sort-by').value) || 'updated_at')
const sort_dir = String((el('dck-sort-dir') && el('dck-sort-dir').value) || 'desc')
const params = new URLSearchParams()
if (q) params.set('q', q)
if (priority) params.set('priority', priority)
if (status) params.set('status', status)
if (activeFolderId !== null) params.set('folder_id', String(activeFolderId))
params.set('sort_by', sort_by)
params.set('sort_dir', sort_dir)
params.set('trash', trashMode ? '1' : '0')
return params.toString()
}
const loadStats = async () => {
const j = await get('/api/doc_cloud_keeper/stats')
const s = j.stats || {}
txt('dck-stat-total', `文件数: ${s.total_files || 0}`)
txt('dck-stat-size', `占用: ${formatSize(s.total_size_bytes || 0)}`)
txt('dck-stat-trash', `回收站: ${s.trash_files || 0}`)
}
const renderBanner = () => {
const expired = docs.filter(r => r.deadline_state === 'expired').length
const urgent = docs.filter(r => r.deadline_state === 'urgent').length
if (!expired && !urgent) {
show('dck-banner', false)
return
}
const parts = []
if (expired) parts.push(`已到期 ${expired} 个`)
if (urgent) parts.push(`1天内到期 ${urgent} 个`)
txt('dck-banner', `提醒:${parts.join(',')}`)
show('dck-banner', true)
}
const buildFolderTree = () => {
const active = folders.filter(f => !f.deleted_at)
const byParent = new Map()
active.forEach(f => {
const pid = Number(f.parent_id || 0)
if (!byParent.has(pid)) byParent.set(pid, [])
byParent.get(pid).push(f)
})
byParent.forEach(arr => arr.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN')))
const out = []
const walk = (pid, depth) => {
const arr = byParent.get(pid) || []
arr.forEach(item => {
out.push({ ...item, depth })
walk(Number(item.id || 0), depth + 1)
})
}
walk(0, 0)
return out
}
const renderFolders = (allDocs = null) => {
const ul = el('dck-folders')
if (!ul) return
ul.innerHTML = ''
// 如果没有传入 allDocs(例如页面刚加载),降级使用当前加载的 docs,但可能不准
const targetDocs = allDocs || docs;
// 计算每个文件夹(及其子文件夹)的文档数量
const countMap = {}
const computeCount = (folderId) => {
if (countMap[folderId] !== undefined) return countMap[folderId]
// 找到直接属于该文件夹的文档数量(不包括回收站的)
let count = targetDocs.filter(d => (folderId === '' ? (d.folder_id === 0 || d.folder_id == null) : d.folder_id === folderId)).length
// 加上所有子文件夹的数量
const children = folders.filter(f => String(f.parent_id || '0') === String(folderId || '0'))
for (const child of children) {
count += computeCount(child.id)
}
countMap[folderId] = count
return count
}
const allCount = targetDocs.length
const all = document.createElement('li')
all.dataset.id = ''
all.className = activeFolderId === null ? 'active' : ''
all.innerHTML = `<div>全部文档 (${allCount})</div>`
all.onclick = () => {
activeFolderId = null
renderFolders()
loadDocs()
}
ul.appendChild(all)
buildFolderTree().forEach(f => {
const li = document.createElement('li')
li.dataset.id = String(f.id)
if (String(activeFolderId) === String(f.id)) li.classList.add('active')
const depth = Number(f.depth || 0)
const spacer = '<span class="dck-folder-depth" style="display:inline-block; width: 16px;"></span>'.repeat(depth)
const count = computeCount(f.id)
li.innerHTML = `<div class="dck-folder-node">
<div style="display:flex; align-items:center; flex:1; overflow:hidden;" title="${esc(f.name)}">${spacer}<span class="dck-folder-name">${esc(f.name)} (${count})</span></div>
<button class="dck-edit-folder-btn" title="修改文件夹">✎</button>
</div>`
const editBtn = li.querySelector('.dck-edit-folder-btn')
if (editBtn) {
editBtn.onclick = async e => {
e.stopPropagation()
const isInvalidParent = (parentId, targetId) => {
if (String(parentId) === String(targetId)) return true
let p = String(parentId)
while (p && p !== '0') {
const f = folders.find(x => String(x.id) === p)
if (!f) break
if (String(f.parent_id) === String(targetId)) return true
p = String(f.parent_id)
}
return false
}
const form = await askForm({
title: '修改文件夹',
message: `正在修改:${f.name}`,
confirmText: '保存',
fields: [
{ key: 'name', label: '名称', type: 'text', required: true, value: f.name, placeholder: '例如:项目A' },
{ key: 'parent_id', label: '父目录', type: 'select', value: String(f.parent_id || '0'), options: folderOptions().filter(opt => !isInvalidParent(opt.value, f.id)).map(x => ({ value: String(x.value), label: x.label })) }
]
})
if (!form) return
await runAction('修改文件夹', async log => {
log(`名称: ${form.name}`)
log(`父目录: ${form.parent_id || 0}`)
await post('/api/doc_cloud_keeper/folders/update', { id: f.id, name: form.name, parent_id: Number(form.parent_id || 0) })
await loadFolders()
if (activeFolderId === f.id) renderFolders() // Re-render to update selected style and title if it was selected
}, '保存文件夹信息并刷新列表')
}
}
li.onclick = () => {
activeFolderId = f.id
renderFolders()
loadDocs()
}
ul.appendChild(li)
})
}
const selectDoc = docId => {
activeDocId = Number(docId || 0)
}
const getDirectFolderName = folderId => {
const id = Number(folderId || 0)
if (!id) return ''
const row = folders.find(f => Number(f.id || 0) === id && !f.deleted_at)
return row ? String(row.name || '').trim() : ''
}
const renderDocs = () => {
const tb = el('dck-tbody')
if (!tb) return
tb.innerHTML = ''
docs.forEach(r => {
const tr = document.createElement('tr')
if (Number(r.id) === Number(activeDocId)) tr.classList.add('active')
if (r.deadline_state === 'expired') tr.classList.add('dck-row-expired')
else if (r.deadline_state === 'urgent') tr.classList.add('dck-row-urgent')
const parentFolderName = getDirectFolderName(r.folder_id)
const versionCount = Math.max(0, Number(r.version_count || 0))
const filenameHtml = `
<span class="dck-doc-title-wrap">
<span class="dck-doc-title">${esc(r.filename)}</span>
${parentFolderName ? `<span class="dck-doc-parent-tag" title="直属父目录:${esc(parentFolderName)}">${esc(parentFolderName)}</span>` : ''}
<button type="button" class="dck-preview-btn" data-doc-id="${r.id}" title="快速查看 ${esc(r.filename)}">👁</button>
<button type="button" class="dck-version-count-tag" data-doc-id="${r.id}" title="查看 ${esc(r.filename)} 的历史版本">${versionCount}</button>
</span>
`
tr.innerHTML = `
<td><input type="checkbox" class="dck-check" data-id="${r.id}" ${selectedIds.has(Number(r.id)) ? 'checked' : ''} /></td>
<td><button class="mini-btn dck-local-btn" data-folder-id="${r.id}" title="打开本地工作目录">📁</button></td>
<td>${filenameHtml}</td>
<td>${esc(r.ext || '')}</td>
<td>${formatSize(r.size_bytes)}</td>
<td>${r.priority === 'high' ? '高' : r.priority === 'low' ? '低' : '中'}</td>
<td>${r.status === 'todo' ? '未处理' : r.status === 'doing' ? '处理中' : '已完成'}</td>
<td>${esc(r.due_at || '-')}</td>
<td>${countdownText(r.deadline_ms)}</td>
<td>${esc(r.updated_at || '')}</td>
`
tr.onclick = e => {
if (e.target && e.target.classList && e.target.classList.contains('dck-check')) return
selectDoc(r.id)
renderDocs()
}
tb.appendChild(tr)
})
Array.from(tb.querySelectorAll('.dck-check')).forEach(c => {
c.onchange = () => {
const id = Number(c.dataset.id || 0)
if (!id) return
if (c.checked) selectedIds.add(id)
else selectedIds.delete(id)
}
})
Array.from(tb.querySelectorAll('button[data-folder-id]')).forEach(btn => {
btn.onclick = async e => {
e.stopPropagation()
const docId = Number(btn.dataset.folderId || 0)
await runAction('打开本地工作目录', async log => {
log(`文档ID: ${docId}`)
await openDocWorkFolder(docId)
log('已打开目录选择器,可快速定位本地工作文件夹')
}, '使用上次记录的位置作为起始目录')
}
})
Array.from(tb.querySelectorAll('.dck-version-count-tag')).forEach(btn => {
btn.onclick = async e => {
e.stopPropagation()
const docId = Number(btn.dataset.docId || 0)
if (!docId) return
const doc = docs.find(item => Number(item.id) === docId)
if (!doc) return
if (Number(activeDocId) !== docId) {
selectDoc(docId)
renderDocs()
}
await openVersions(doc)
}
})
Array.from(tb.querySelectorAll('.dck-preview-btn')).forEach(btn => {
btn.onclick = async e => {
e.stopPropagation()
const docId = Number(btn.dataset.docId || 0)
if (!docId) return
const doc = docs.find(item => Number(item.id) === docId)
if (!doc) return
await openPreview(doc)
}
})
renderBanner()
updateActionButtonsState()
}
const setBtnDisabled = (id, disabled) => {
const n = el(id)
if (!n) return
n.disabled = !!disabled
}
const updateActionButtonsState = () => {
const inTrash = !!trashMode
setBtnDisabled('dck-hard-delete', !inTrash)
setBtnDisabled('dck-restore', !inTrash)
setBtnDisabled('dck-bulk-trash', inTrash)
setBtnDisabled('dck-bulk-move', inTrash)
setBtnDisabled('dck-upload-new', inTrash)
setBtnDisabled('dck-upload-cover', inTrash)
setBtnDisabled('dck-rename', inTrash)
setBtnDisabled('dck-move', inTrash)
setBtnDisabled('dck-meta-edit', inTrash)
setBtnDisabled('dck-trash', inTrash)
setBtnDisabled('dck-bulk-hard-delete', !inTrash)
}
const loadFolders = async () => {
const j = await get('/api/doc_cloud_keeper/folders/list')
folders = Array.isArray(j.rows) ? j.rows : []
// 为了每次重新计算数量,我们也需要获取全局所有文件,否则只看当前目录的无法统计总数
const allDocsReq = await get('/api/doc_cloud_keeper/docs/list?all=1')
const allDocs = Array.isArray(allDocsReq.rows) ? allDocsReq.rows : []
renderFolders(allDocs)
}
const loadDocs = async () => {
const q = currentQuery()
const j = await get(`/api/doc_cloud_keeper/docs/list?${q}`)
docs = applyDeadlineState(Array.isArray(j.rows) ? j.rows : [])
if (routeDocId) {
const hit = docs.find(d => Number(d.id) === Number(routeDocId))
if (hit) {
activeDocId = Number(routeDocId)
routeDocId = 0
}
}
if (!docs.find(d => Number(d.id) === Number(activeDocId))) activeDocId = 0
renderDocs()
// 每次加载文档后,顺便更新一次左侧树的数字,确保绝对同步
loadFolders().catch(() => {})
}
const refreshAll = async () => {
await loadStats()
await loadFolders()
await loadDocs()
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const waitBackendReady = async (message, checkFn, options = {}) => {
const maxTry = Math.max(1, Number(options.maxTry || 8))
const intervalMs = Math.max(100, Number(options.intervalMs || 500))
for (let i = 1; i <= maxTry; i += 1) {
if (await checkFn()) return true
if (i < maxTry) {
setActionStatus(`${message} (${i}/${maxTry})`, '')
await sleep(intervalMs)
}
}
return false
}
const startLiveSync = () => {
if (liveSyncTimer) clearInterval(liveSyncTimer)
liveSyncTimer = setInterval(async () => {
if (!me || actionDepth > 0 || formResolver || document.hidden) return
if (liveSyncBusy) return
liveSyncBusy = true
try {
await loadStats()
await loadDocs()
} catch {}
liveSyncBusy = false
}, 180000)
}
const ensureSelectedDoc = () => {
const row = docs.find(d => Number(d.id) === Number(activeDocId))
if (!row) {
modalAlert('提示', '没有选中记录,请点击表格里的某一行记录(不是前面勾选)')
throw new Error('未选择文档')
}
return row
}
const sha256Hex = async arrayBuffer => {
if (window.crypto && window.crypto.subtle && arrayBuffer) {
const hash = await window.crypto.subtle.digest('SHA-256', arrayBuffer)
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('')
}
return ''
}
const uploadTemp = async file => {
const buf = await file.arrayBuffer()
const checksum = await sha256Hex(buf)
const u = `/api/doc_cloud_keeper/upload/temp?filename=${encodeURIComponent(file.name || 'document.txt')}${checksum ? `&sha256=${checksum}` : ''}`
let lastErr = null
const maxTry = 3
for (let i = 1; i <= maxTry; i += 1) {
try {
logLine(`上传临时文件: ${file.name} (${formatSize(file.size)}), 尝试 ${i}/${maxTry}`)
const r = await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: buf })
logLine(`临时上传响应: ${r.status}`)
const data = await r.json().catch(() => null)
if (!r.ok || !data || data.ok === false) throw new Error((data && data.error) || '上传临时文件失败')
return data
} catch (e) {
lastErr = e
if (i < maxTry) await new Promise(resolve => setTimeout(resolve, i * 500))
}
}
throw (lastErr || new Error('上传失败'))
}
const pickFile = async () => {
if (window.showOpenFilePicker) {
try {
const options = { multiple: false }
if (rememberedFsHandle) options.startIn = rememberedFsHandle
const handles = await window.showOpenFilePicker(options)
if (!handles || !handles[0]) return null
const fileHandle = handles[0]
rememberedFsHandle = fileHandle
await saveRememberedHandle(rememberedFsHandle)
const file = await fileHandle.getFile()
return file ? { file, handle: fileHandle } : null
} catch (e) {
if (e && e.name === 'AbortError') return null
}
}
return new Promise(resolve => {
const picker = el('dck-file-picker')
if (!picker) return resolve(null)
picker.onchange = e => {
const file = e.target.files && e.target.files[0]
resolve(file ? { file, handle: null } : null)
}
picker.value = ''
picker.click()
})
}
const saveDownloadAs = async (doc, url) => {
if (window.showSaveFilePicker) {
const options = { suggestedName: doc.filename || 'document' }
if (rememberedFsHandle) options.startIn = rememberedFsHandle
const fileHandle = await window.showSaveFilePicker(options)
rememberedFsHandle = fileHandle
await saveRememberedHandle(rememberedFsHandle)
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error(`下载失败: ${r.status}`)
const blob = await r.blob()
const writable = await fileHandle.createWritable()
await writable.write(blob)
await writable.close()
return { mode: 'picker', fileHandle }
}
window.open(url, '_blank')
return { mode: 'browser' }
}
const setOssProbeResult = (html, cls) => {
const box = el('dck-oss-probe-result')
if (!box) return
box.className = `dck-oss-probe-result${cls ? ` ${cls}` : ''}`
box.innerHTML = html
}
const appendOssProbeLog = (line, type) => {
const box = el('dck-oss-probe-logs')
if (!box) return
const div = document.createElement('div')
div.className = `dck-oss-probe-log-line${type ? ` ${type}` : ''}`
div.textContent = `[${nowStr()}] ${String(line || '')}`
box.appendChild(div)
box.scrollTop = box.scrollHeight
}
const renderOssProbeFile = () => {
const box = el('dck-oss-probe-file')
if (!box) return
if (!ossProbePicked || !ossProbePicked.file) {
box.textContent = '尚未选择文件'
return
}
const file = ossProbePicked.file
box.textContent = [
`文件名: ${file.name || '-'}`,
`大小: ${formatSize(file.size || 0)} (${Number(file.size || 0)} bytes)`,
`类型: ${file.type || 'application/octet-stream'}`
].join('\n')
}
const clearOssProbeView = () => {
ossProbePicked = null
renderOssProbeFile()
const logs = el('dck-oss-probe-logs')
if (logs) logs.innerHTML = ''
setOssProbeResult('等待执行检测', '')
}
const openOssProbeModal = () => {
show('dck-oss-probe-modal', true)
if (!el('dck-oss-probe-logs') || !el('dck-oss-probe-logs').childNodes.length) clearOssProbeView()
}
const closeOssProbeModal = () => {
show('dck-oss-probe-modal', false)
}
const pickOssProbeFile = async () => new Promise(resolve => {
const picker = el('dck-oss-probe-picker')
if (!picker) return resolve(null)
picker.onchange = e => {
const file = e.target.files && e.target.files[0]
resolve(file ? { file } : null)
}
picker.value = ''
picker.click()
})
const runOssProbeUpload = async () => {
if (!ossProbePicked || !ossProbePicked.file) {
modalAlert('提示', '请先在第一栏选择一个文件。')
return
}
const file = ossProbePicked.file
const uploadBtn = el('dck-oss-probe-upload')
if (uploadBtn) uploadBtn.disabled = true
const logs = el('dck-oss-probe-logs')
if (logs) logs.innerHTML = ''
appendOssProbeLog('开始执行 OSS 检测')
appendOssProbeLog(`已选文件: ${file.name}`)
appendOssProbeLog(`文件大小: ${Number(file.size || 0)} bytes`)
setOssProbeResult('检测进行中,请等待服务器返回详细日志...', '')
try {
appendOssProbeLog('开始读取本地文件内容')
const buf = await file.arrayBuffer()
appendOssProbeLog(`本地读取完成: ${buf.byteLength} bytes`)
const url = `/api/doc_cloud_keeper/oss_probe/upload?filename=${encodeURIComponent(file.name || 'oss_probe.txt')}&mime=${encodeURIComponent(file.type || 'application/octet-stream')}`
appendOssProbeLog(`开始请求: POST ${url}`)
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: buf
})
appendOssProbeLog(`HTTP 状态: ${resp.status}`)
const data = await resp.json().catch(() => null)
if (data && Array.isArray(data.logs)) {
data.logs.forEach(line => appendOssProbeLog(line, data.ok ? '' : 'err'))
}
if (!resp.ok || !data || data.ok === false) {
const errText = (data && data.error) || `请求失败: ${resp.status}`
appendOssProbeLog(`检测失败: ${errText}`, 'err')
const failedResult = data && data.result ? data.result : {}
const failedKey = failedResult.oss_key ? `\nOSS Key: ${failedResult.oss_key}` : ''
const failedUrl = failedResult.object_url ? `\n对象地址: ${failedResult.object_url}` : ''
setOssProbeResult(`状态: 失败\n错误: ${errText}${failedKey}${failedUrl}`.replace(/\n/g, '<br>'), 'err')
return
}
const result = data.result || {}
appendOssProbeLog('检测结果已返回', 'ok')
const downloadUrl = esc(result.download_url || '')
const objectUrl = esc(result.object_url || '')
const objectKey = esc(result.oss_key || '')
setOssProbeResult(`
状态: 上传成功
文件名: ${esc(result.filename || file.name || '-')}
文件大小: ${formatSize(result.size_bytes || file.size || 0)} (${Number(result.size_bytes || file.size || 0)} bytes)
OSS Key: ${objectKey}
原始对象地址: <a href="${objectUrl}" target="_blank" rel="noopener noreferrer">${objectUrl}</a>
下载地址(24小时): <a href="${downloadUrl}" target="_blank" rel="noopener noreferrer">${downloadUrl}</a>
ETag: ${esc(result.etag || '-')}
RequestId: ${esc(result.request_id || '-')}
HEAD RequestId: ${esc(result.head_request_id || '-')}
SHA256: ${esc(result.sha256 || '-')}
`.trim().replace(/\n/g, '<br>'), 'ok')
} catch (err) {
const errText = actionErrorText(err)
appendOssProbeLog(`检测异常: ${errText}`, 'err')
setOssProbeResult(`状态: 失败\n错误: ${esc(errText)}`.replace(/\n/g, '<br>'), 'err')
} finally {
if (uploadBtn) uploadBtn.disabled = false
}
}
const openDocWorkFolder = async docId => {
const id = Number(docId || 0)
if (!id) throw new Error('无效文档ID')
if (!window.showDirectoryPicker) throw new Error('当前浏览器不支持“打开文件夹”')
const handle = await loadDocHandle(id)
if (!handle) throw new Error('该文档暂未记录本地目录,请先用“下载”并选择目录,或从本地覆盖上传一次')
const dirHandle = await window.showDirectoryPicker({ startIn: handle })
rememberedFsHandle = dirHandle
await saveRememberedHandle(dirHandle)
return dirHandle
}
const folderOptions = () => {
const list = [{ value: '0', label: '根目录' }]
const addChildren = (parentId, prefix) => {
folders.filter(f => String(f.parent_id || '0') === String(parentId)).forEach(f => {
list.push({ value: String(f.id), label: `${prefix}${f.name}` })
addChildren(f.id, `${prefix.replace('└─ ', '\u00A0\u00A0\u00A0')}└─ `) // 使用空格和树形符号
})
}
addChildren('0', '└─ ')
return list
}
const compareTypeText = type => {
if (type === 'excel') return 'Excel'
if (type === 'word') return 'Word'
if (type === 'pdf') return 'PDF'
if (type === 'mixed') return '混合类型'
return '通用文档'
}
const renderCompareListHtml = items => {
const list = Array.isArray(items) ? items.filter(Boolean) : []
if (!list.length) return '<div style="color:#64748b;">暂无数据</div>'
return `<ul class="dck-compare-list">${list.map(item => `<li>${esc(item)}</li>`).join('')}</ul>`
}
const closeCompareModal = () => show('dck-compare-modal', false)
const filteredCompareSections = cmp => {
const sections = Array.isArray(cmp && cmp.detail_sections) ? cmp.detail_sections : []
if (!cmp || cmp.file_type !== 'excel' || compareExcelSheetFilter === 'all') return sections
return sections.filter(section => !section.sheet_name || section.sheet_name === compareExcelSheetFilter)
}
const excelStatsSummaryHtml = cmp => {
const stats = cmp && cmp.excel_stats ? cmp.excel_stats : null
if (!stats) return ''
const changedSheets = Array.isArray(stats.changed_sheets) ? stats.changed_sheets : []
const filterOptions = ['<option value="all">全部工作表</option>']
.concat(changedSheets.map(item => `<option value="${esc(item.sheet_name)}"${compareExcelSheetFilter === item.sheet_name ? ' selected' : ''}>${esc(item.sheet_name)}</option>`))
.join('')
const changedSheetItems = changedSheets.map(item => `${item.sheet_name}:单元格 ${item.cell_changes},公式 ${item.formula_changes},范围 ${item.left_rows}x${item.left_cols} -> ${item.right_rows}x${item.right_cols}`)
return `
<div class="dck-compare-block">
<div class="dck-compare-block-title">工作表统计</div>
${renderCompareListHtml([
`左侧工作表:${stats.total_sheets_left || 0}`,
`右侧工作表:${stats.total_sheets_right || 0}`,
`变化工作表:${stats.changed_sheet_count || 0}`,
`变化单元格:${stats.changed_cell_count || 0}`,
`变化公式:${stats.changed_formula_count || 0}`
])}
</div>
<div class="dck-compare-block">
<div class="dck-compare-block-title">工作表筛选</div>
<div class="dck-compare-filter-row">
<label for="dck-compare-sheet-filter">只看工作表</label>
<select id="dck-compare-sheet-filter" class="modal-input dck-compare-filter-select">${filterOptions}</select>
</div>
</div>
${changedSheetItems.length ? `<div class="dck-compare-block"><div class="dck-compare-block-title">按表变化数量</div>${renderCompareListHtml(changedSheetItems)}</div>` : ''}
`
}
const renderCompareModal = comparison => {
const cmp = comparison || {}
txt('dck-compare-title', `版本差异摘要 - ${compareTypeText(cmp.file_type)}`)
const warnings = Array.isArray(cmp.warnings) ? cmp.warnings : []
const sections = filteredCompareSections(cmp)
const metaHtml = `
<div class="dck-compare-block">
<div class="dck-compare-block-title">左侧版本</div>
${renderCompareListHtml([
`标签:${(cmp.left && cmp.left.label) || '-'}`,
`文件:${(cmp.left && cmp.left.filename) || '-'}`,
`大小:${formatSize(cmp.left && cmp.left.size_bytes)}`,
`时间:${(cmp.left && cmp.left.created_at) || '-'}`
])}
</div>
<div class="dck-compare-block">
<div class="dck-compare-block-title">右侧版本</div>
${renderCompareListHtml([
`标签:${(cmp.right && cmp.right.label) || '-'}`,
`文件:${(cmp.right && cmp.right.filename) || '-'}`,
`大小:${formatSize(cmp.right && cmp.right.size_bytes)}`,
`时间:${(cmp.right && cmp.right.created_at) || '-'}`
])}
</div>
${warnings.length ? `<div class="dck-compare-block dck-compare-warning"><div class="dck-compare-block-title">提示</div>${renderCompareListHtml(warnings)}</div>` : ''}
`
const summaryHtml = `${cmp.file_type === 'excel' ? excelStatsSummaryHtml(cmp) : ''}<div class="dck-compare-block">${renderCompareListHtml(cmp.summary_items || [])}</div>`
const detailsHtml = sections.length
? sections.map(section => `<div class="dck-compare-block"><div class="dck-compare-block-title">${esc(section.title || '明细')}</div>${renderCompareListHtml(section.items || [])}</div>`).join('')
: '<div class="dck-compare-block"><div style="color:#64748b;">当前没有更细的差异明细。</div></div>'
const meta = el('dck-compare-meta')
const summary = el('dck-compare-summary')
const details = el('dck-compare-details')
if (meta) meta.innerHTML = metaHtml
if (summary) summary.innerHTML = summaryHtml
if (details) details.innerHTML = detailsHtml
const sheetFilter = el('dck-compare-sheet-filter')
if (sheetFilter) {
sheetFilter.onchange = () => {
compareExcelSheetFilter = String(sheetFilter.value || 'all')
renderCompareModal(cmp)
}
}
}
const openCompareLoading = (left, right) => {
compareExcelSheetFilter = 'all'
txt('dck-compare-title', '版本差异摘要 - 分析中')
const meta = el('dck-compare-meta')
const summary = el('dck-compare-summary')
const details = el('dck-compare-details')
if (meta) meta.innerHTML = `
<div class="dck-compare-block">
<div class="dck-compare-block-title">待比较版本</div>
${renderCompareListHtml([`左侧:${left.label}`, `右侧:${right.label}`])}
</div>
`
if (summary) summary.innerHTML = '<div class="dck-compare-block">正在分析文件结构与内容差异,请稍候...</div>'
if (details) details.innerHTML = '<div class="dck-compare-block">第一版会返回摘要和关键明细。</div>'
show('dck-compare-modal', true)
}
const runVersionCompare = async () => {
if (localVersionCompareSelections.length !== 2) {
modalAlert('提示', '请先在云服务器页签中勾选两个版本,再点击“比较差异”。')
return
}
const [left, right] = localVersionCompareSelections
openCompareLoading(left, right)
try {
const resp = await post('/api/doc_cloud_keeper/docs/compare_versions', {
left: { target_type: left.target_type, id: left.id },
right: { target_type: right.target_type, id: right.id }
})
renderCompareModal(resp.comparison || {})
} catch (e) {
const errText = actionErrorText(e)
txt('dck-compare-title', '版本差异摘要 - 失败')
const summary = el('dck-compare-summary')
const details = el('dck-compare-details')
if (summary) summary.innerHTML = `<div class="dck-compare-block dck-compare-warning">${esc(errText)}</div>`
if (details) details.innerHTML = '<div class="dck-compare-block">比较已中断,请确认文件类型和版本文件是否完整。</div>'
}
}
const setVersionsTab = tab => {
versionsTab = tab === 'oss' ? 'oss' : 'local'
const localBtn = el('dck-versions-tab-local')
const ossBtn = el('dck-versions-tab-oss')
const compareBtn = el('dck-versions-compare')
const refreshBtn = el('dck-versions-oss-refresh')
const localPanel = el('dck-versions-list')
const ossPanel = el('dck-oss-versions-list')
if (localBtn) localBtn.classList.toggle('active', versionsTab === 'local')
if (ossBtn) ossBtn.classList.toggle('active', versionsTab === 'oss')
if (compareBtn) compareBtn.style.display = versionsTab === 'local' ? '' : 'none'
if (refreshBtn) refreshBtn.style.display = versionsTab === 'oss' ? '' : 'none'
if (localPanel) localPanel.style.display = versionsTab === 'local' ? '' : 'none'
if (ossPanel) ossPanel.style.display = versionsTab === 'oss' ? '' : 'none'
syncVersionCompareButton()
}
const renderLocalVersions = (box, versions, doc) => {
if (!box) return
const items = [{
id: doc.id,
target_type: 'current',
compare_label: '当前文件',
title: '当前文件',
filename: doc.filename,
size_bytes: doc.size_bytes || 0,
created_at: doc.updated_at || doc.created_at || '',
ext: doc.ext || '',
download_kind: 'current'
}, ...(Array.isArray(versions) ? versions.map(v => ({
...v,
target_type: 'version',
compare_label: `V${v.version_no} - ${v.action}`,
title: `V${v.version_no} - ${v.action}`,
download_kind: 'version'
})) : [])]
localVersionCompareItems = items
localVersionCompareSelections = localVersionCompareSelections.filter(sel => items.some(item => versionCompareKey(item) === versionCompareKey(sel)))
syncVersionCompareButton()
box.innerHTML = ''
if (!items.length) {
box.innerHTML = '<div style="text-align:center;color:#6b7280;padding:20px;">暂无可比较版本</div>'
return
}
items.forEach(v => {
const checked = isVersionSelected(v) ? 'checked' : ''
const actionButtons = v.target_type === 'current'
? `<button class="mini-btn" data-current-dl="${v.id}">下载当前版</button>`
: `<button class="mini-btn" data-dl="${v.id}">下载</button><button class="mini-btn" data-rs="${v.id}">恢复为当前版</button>`
const row = document.createElement('div')
row.className = 'dck-version-row'
row.innerHTML = `
<div class="dck-version-main">
<div class="dck-version-title">
<label class="dck-version-select"><input type="checkbox" data-cmp-type="${v.target_type}" data-cmp-id="${v.id}" ${checked} />对比</label>
<span>${esc(v.title)}</span>
</div>
<div class="dck-version-meta">文件: ${esc(v.filename)} | 大小: ${formatSize(v.size_bytes)} | 时间: ${esc(v.created_at || '')}</div>
</div>
<div style="display:flex;gap:8px;">
${actionButtons}
</div>
`
box.appendChild(row)
})
Array.from(box.querySelectorAll('input[data-cmp-type]')).forEach(input => {
input.onchange = () => {
const item = items.find(x => String(x.target_type) === String(input.dataset.cmpType || '') && String(x.id) === String(input.dataset.cmpId || ''))
if (!item) return
const ok = toggleVersionCompareSelection(item)
if (!ok) {
input.checked = false
modalAlert('提示', '第一版一次只能选择两个版本进行比较。')
return
}
renderLocalVersions(box, versions, doc)
}
})
Array.from(box.querySelectorAll('button[data-current-dl]')).forEach(btn => {
btn.onclick = async () => {
await runAction('下载当前版文件', async lg => {
const currentId = btn.dataset.currentDl || ''
lg(`文档ID: ${currentId}`)
const out = await saveDownloadAs(doc, `/api/doc_cloud_keeper/docs/download?id=${encodeURIComponent(currentId)}`)
if (out.mode === 'picker') {
lg('已保存到上次使用的目录')
setOpenFolderButton(true)
} else lg('已触发浏览器下载流程')
}, '下载当前文件')
}
})
Array.from(box.querySelectorAll('button[data-dl]')).forEach(btn => {
btn.onclick = async () => {
await runAction('下载历史版本', async lg => {
const versionId = btn.dataset.dl || ''
lg(`版本ID: ${versionId}`)
const url = `/api/doc_cloud_keeper/docs/version_download?id=${encodeURIComponent(versionId)}`
const v = versions.find(x => String(x.id) === String(versionId))
let friendlyName = `version-${versionId}`
if (v) {
const ext = v.ext ? `.${v.ext}` : ''
friendlyName = buildHistoryDownloadName({
filename: v.filename || `version-${v.version_no || versionId}${ext}`,
size_bytes: v.size_bytes || 0,
record_time: v.created_at || '',
upload_time: '',
source_name: `云服务器版本库${ext}`
})
}
const out = await saveDownloadAs({ filename: friendlyName }, url)
if (out.mode === 'picker') {
lg('已保存到上次使用的目录')
setOpenFolderButton(true)
} else lg('已触发浏览器下载流程')
}, '下载版本文件')
}
})
Array.from(box.querySelectorAll('button[data-rs]')).forEach(btn => {
btn.onclick = async () => {
const yes = await askConfirm('恢复版本', '恢复后会生成新的版本记录,确认继续?', '确认恢复')
if (!yes) return
await runAction('恢复历史版本', async lg => {
lg(`文档ID: ${doc.id}`)
lg(`版本ID: ${btn.dataset.rs}`)
await post('/api/doc_cloud_keeper/docs/restore_version', { doc_id: doc.id, version_id: Number(btn.dataset.rs || 0) })
await refreshAll()
}, '恢复并保留审计链')
}
})
}
const ossStatusText = status => {
const s = String(status || '').toLowerCase()
if (s === 'uploaded') return '已备份'
if (s === 'failed') return '补传失败'
return '等待补传'
}
const renderOssVersions = (box, versions) => {
if (!box) return
box.innerHTML = ''
if (!versions.length) {
box.innerHTML = '<div style="text-align:center;color:#6b7280;padding:20px;">暂无 OSS 备份记录</div>'
return
}
versions.forEach(v => {
const status = String(v.backup_status || 'pending').toLowerCase()
const title = v.source_kind === 'version'
? `V${Number(v.version_no || 0) || '?'} - ${esc(v.action || '历史版本')}`
: '当前文件备份'
const deletedMark = v.is_physical_deleted ? '<span class="dck-oss-deleted">【物理删除】</span>' : ''
const retryInfo = status === 'failed' && v.last_error
? `<div class="dck-version-meta" style="color:#b91c1c;margin-top:4px;">失败原因: ${esc(v.last_error)}</div>`
: ''
const row = document.createElement('div')
row.className = 'dck-version-row'
row.innerHTML = `
<div class="dck-version-main">
<div class="dck-version-title">
<span>${title}</span>
<span class="dck-oss-status ${status}">${ossStatusText(status)}</span>
${deletedMark}
</div>
<div class="dck-version-meta">文件: ${esc(v.filename)} | 大小: ${formatSize(v.size_bytes)} | 记录时间: ${esc(v.created_at || '')}${v.last_uploaded_at ? ` | 上传时间: ${esc(v.last_uploaded_at)}` : ''}</div>
${retryInfo}
</div>
<div style="display:flex;gap:8px;">
<button class="mini-btn" data-oss-dl="${v.id}" ${status !== 'uploaded' ? 'disabled' : ''}>下载</button>
</div>
`
box.appendChild(row)
})
Array.from(box.querySelectorAll('button[data-oss-dl]')).forEach(btn => {
btn.onclick = async () => {
await runAction('下载 OSS 备份', async lg => {
const backupId = btn.dataset.ossDl || ''
lg(`OSS 备份ID: ${backupId}`)
const url = `/api/doc_cloud_keeper/docs/oss_version_download?id=${encodeURIComponent(backupId)}`
const v = versions.find(x => String(x.id) === String(backupId))
let friendlyName = `oss-backup-${backupId}`
if (v) {
const ext = v.ext ? `.${v.ext}` : ''
friendlyName = buildHistoryDownloadName({
filename: v.filename || `oss-backup-${backupId}${ext}`,
size_bytes: v.size_bytes || 0,
record_time: v.created_at || '',
upload_time: v.last_uploaded_at || '',
source_name: `OSS容灾库${ext}`,
is_deleted: !!v.is_physical_deleted
})
}
const out = await saveDownloadAs({ filename: friendlyName }, url)
if (out.mode === 'picker') {
lg('已保存到上次使用的目录')
setOpenFolderButton(true)
} else lg('已触发浏览器下载流程')
}, '从 OSS 容灾备份下载文件')
}
})
}
const reloadOssVersions = async (doc, opts = {}) => {
const targetDoc = doc || ensureSelectedDoc()
const quiet = !!(opts && opts.quiet)
const doLoad = async log => {
log(`文档ID: ${targetDoc.id}`)
const ossResp = await get(`/api/doc_cloud_keeper/docs/oss_versions?id=${encodeURIComponent(targetDoc.id)}`)
renderOssVersions(el('dck-oss-versions-list'), ossResp.versions || [])
setVersionsTab('oss')
}
if (quiet) return doLoad(() => {})
return runAction('刷新 OSS 状态', doLoad, '重新读取当前文档的 OSS 备份状态')
}
const openVersions = async targetDoc => {
const doc = targetDoc || ensureSelectedDoc()
await runAction('加载版本列表', async log => {
log(`文档ID: ${doc.id}`)
localVersionCompareSelections = []
localVersionCompareItems = []
const [localResp, ossResp] = await Promise.all([
get(`/api/doc_cloud_keeper/docs/versions?id=${encodeURIComponent(doc.id)}`),
get(`/api/doc_cloud_keeper/docs/oss_versions?id=${encodeURIComponent(doc.id)}`)
])
txt('dck-versions-title', `历史版本 - ${doc.filename || '文档'}`)
renderLocalVersions(el('dck-versions-list'), localResp.versions || [], doc)
renderOssVersions(el('dck-oss-versions-list'), ossResp.versions || [])
setVersionsTab(versionsTab)
show('dck-versions-modal', true)
}, '读取历史版本记录与 OSS 容灾备份')
}
const startLocalEdit = async () => {
const doc = ensureSelectedDoc()
await runAction('唤起本地在线编辑', async log => {
log(`准备编辑文档ID: ${doc.id}`)
log('向服务器申请一次性下载 Token...')
const { token } = await post('/api/doc_cloud_keeper/docs/get_edit_token', { id: doc.id })
if (!token) throw new Error('无法获取编辑 Token')
const serverHost = window.location.origin // e.g. http://localhost:8976
const targetUrl = `traedoc://edit?id=${encodeURIComponent(doc.id)}&token=${encodeURIComponent(token)}&host=${encodeURIComponent(serverHost)}`
log('正在尝试唤起本地编辑助手...')
log(`指令:${targetUrl}`)
// 尝试打开自定义协议
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = targetUrl
document.body.appendChild(iframe)
setTimeout(() => {
document.body.removeChild(iframe)
log('唤起指令已发送!')
log('如果您的电脑没有反应,说明尚未安装【本地编辑助手】。')
log('请前往目录 d:\\Trae_Files\\TRAE-Toolbox\\winform\\DocHelper\\ 找到 DocHelper.exe。')
log('首次使用,请以管理员身份在命令行运行: DocHelper.exe /install')
log('安装后即可顺滑体验“点击 -> WPS修改 -> 自动回传保存”!')
}, 1000)
// 这里不抛出错误,因为这是异步唤起,浏览器不会报错,而是由操作系统决定是否弹窗
}, '唤起本地应用')
}
const login = async () => {
const username = String((el('dck-username') && el('dck-username').value) || '').trim()
const password = String((el('dck-password') && el('dck-password').value) || '')
if (!username || !password) return
try {
const loginBtn = el('dck-login-btn')
if (loginBtn) loginBtn.disabled = true
if (remember) {
localStorage.setItem('dck_saved_username', username)
} else {
localStorage.removeItem('dck_saved_username')
}
await post('/api/doc_cloud_keeper/login', { username, password })
show('dck-login', false)
show('dck-app', true)
setHeaderActionsVisible(true)
await refreshAll()
} catch (e) {
txt('dck-login-msg', e.message || e)
show('dck-login-msg', true)
} finally {
const loginBtn = el('dck-login-btn')
if (loginBtn) loginBtn.disabled = false
}
}
const ensureAuth = async () => {
try {
const j = await get('/api/doc_cloud_keeper/me')
me = j.user || null
show('dck-login', false)
show('dck-app', true)
setHeaderActionsVisible(true)
await refreshAll()
return
} catch {}
show('dck-app', false)
show('dck-login', true)
setHeaderActionsVisible(false)
}
const bindModalEvents = () => {
const closeAction = () => closeActionModal()
const a1 = el('dck-action-close')
const a2 = el('dck-action-close-bottom')
if (a1) a1.onclick = closeAction
if (a2) a2.onclick = closeAction
const copyAll = el('dck-action-copy-all')
if (copyAll) copyAll.onclick = async () => {
const lines = Array.from((el('dck-action-logs') || document.createElement('div')).querySelectorAll('.dck-log-line')).map(n => n.textContent || '')
await copyText(lines.join('\n'))
}
const copyErr = el('dck-action-copy-error')
if (copyErr) copyErr.onclick = async () => {
const lines = Array.from((el('dck-action-logs') || document.createElement('div')).querySelectorAll('.dck-log-line.err')).map(n => n.textContent || '')
await copyText(lines.join('\n'))
}
const openFolderBtn = el('dck-action-open-folder')
if (openFolderBtn) openFolderBtn.onclick = async () => {
await runAction('打开文件夹', async log => {
await openRememberedFolder()
log('已打开目录选择器,可直接定位到上次工作目录')
}, '打开本地工作目录')
}
const closeForm = () => {
closeFormModal()
if (formResolver) {
const r = formResolver
formResolver = null
r(null)
}
}
const fClose = el('dck-form-close')
const fCancel = el('dck-form-cancel')
if (fClose) fClose.onclick = closeForm
if (fCancel) fCancel.onclick = closeForm
const fCopy = el('dck-form-copy')
if (fCopy) fCopy.onclick = async () => {
await copyText(JSON.stringify(collectFormValues(), null, 2))
}
const fConfirm = el('dck-form-confirm')
if (fConfirm) fConfirm.onclick = () => {
try {
validateForm()
} catch (e) {
txt('dck-form-msg', String(e.message || e))
return
}
const values = collectFormValues()
closeFormModal()
if (formResolver) {
const r = formResolver
formResolver = null
r(values)
}
}
}
const bind = () => {
bindModalEvents()
const loginBtn = el('dck-login-btn')
if (loginBtn) loginBtn.onclick = login
;(async function tryAutoFill() {
try {
const token = localStorage.getItem('toolbox_creds_token') || ''
const r = await fetch('/api/doc_cloud_keeper/auth/local_creds', {
headers: token ? { 'X-Local-Creds-Token': token } : {},
credentials: 'same-origin',
cache: 'no-store'
})
const d = await r.json().catch(() => null)
if (d && d.ok && d.creds) {
if (d.creds.username) el('dck-username').value = d.creds.username
if (d.creds.password) el('dck-password').value = d.creds.password
}
} catch {}
el('dck-username').removeAttribute('readonly')
el('dck-password').removeAttribute('readonly')
})()
const pwd = el('dck-password')
if (pwd) pwd.onkeydown = e => { if (e.key === 'Enter') login() }
const logout = el('dck-logout')
if (logout) logout.onclick = async () => {
try { await post('/api/doc_cloud_keeper/logout', {}) } catch {}
me = null
docs = []
folders = []
selectedIds.clear()
activeDocId = 0
activeFolderId = null
show('dck-app', false)
show('dck-login', true)
setHeaderActionsVisible(false)
txt('dck-login-msg', '已退出,请重新登录。')
show('dck-login-msg', true)
}
const refresh = el('dck-refresh')
if (refresh) refresh.onclick = () => runAction('刷新数据', async () => { await refreshAll() }, '同步最新文档与统计')
const ossProbeOpen = el('dck-oss-probe-open')
if (ossProbeOpen) ossProbeOpen.onclick = openOssProbeModal
const ossProbeClose = el('dck-oss-probe-close')
if (ossProbeClose) ossProbeClose.onclick = closeOssProbeModal
const ossProbePick = el('dck-oss-probe-pick')
if (ossProbePick) ossProbePick.onclick = async () => {
const picked = await pickOssProbeFile()
if (!picked || !picked.file) return
ossProbePicked = picked
renderOssProbeFile()
appendOssProbeLog(`已选择文件: ${picked.file.name}`)
setOssProbeResult('文件已就绪,点击“上传检测”开始。', '')
}
const ossProbeUpload = el('dck-oss-probe-upload')
if (ossProbeUpload) ossProbeUpload.onclick = runOssProbeUpload
const ossProbeClear = el('dck-oss-probe-clear')
if (ossProbeClear) ossProbeClear.onclick = clearOssProbeView
const query = el('dck-query')
if (query) query.onclick = () => runAction('查询文档', async () => { await loadDocs() }, '按当前筛选条件查询')
const search = el('dck-search')
if (search) search.onkeydown = e => { if (e.key === 'Enter') runAction('查询文档', async () => { await loadDocs() }, '按当前筛选条件查询') }
const folderCreate = el('dck-folder-create')
if (folderCreate) folderCreate.onclick = async () => {
const form = await askForm({
title: '新建文件夹',
message: '请输入文件夹名称',
confirmText: '创建',
fields: [
{ key: 'name', label: '名称', type: 'text', required: true, placeholder: '例如:项目A' },
{ key: 'parent_id', label: '父目录', type: 'select', value: '0', options: folderOptions().map(x => ({ value: String(x.value), label: x.label })) }
]
})
if (!form) return
await runAction('新建文件夹', async log => {
log(`名称: ${form.name}`)
log(`父目录: ${form.parent_id || 0}`)
await post('/api/doc_cloud_keeper/folders/create', { name: form.name, parent_id: Number(form.parent_id || 0) })
await loadFolders()
}, '创建文件夹并刷新列表')
}
const toggleTrash = el('dck-toggle-trash')
if (toggleTrash) toggleTrash.onclick = async () => {
trashMode = !trashMode
txt('dck-toggle-trash', trashMode ? '返回列表' : '回收站')
selectedIds.clear()
await runAction('切换视图', async log => {
log(`当前模式: ${trashMode ? '回收站' : '文档列表'}`)
await loadDocs()
}, '刷新当前列表')
}
const checkAll = el('dck-check-all')
if (checkAll) checkAll.onchange = () => {
selectedIds.clear()
if (checkAll.checked) docs.forEach(r => selectedIds.add(Number(r.id)))
renderDocs()
}
const localEditBtn = el('dck-local-edit')
if (localEditBtn) localEditBtn.onclick = startLocalEdit
const uploadNew = el('dck-upload-new')
if (uploadNew) uploadNew.onclick = async () => {
const picked = await pickFile()
if (!picked || !picked.file) return
const file = picked.file
const form = await askForm({
title: '上传新文档',
message: `文件:${file.name}`,
confirmText: '上传',
fields: [
{ key: 'priority', label: '优先级', type: 'select', value: 'medium', options: [{ value: 'high', label: '高' }, { value: 'medium', label: '中' }, { value: 'low', label: '低' }] },
{ key: 'status', label: '状态', type: 'select', value: 'todo', options: [{ value: 'todo', label: '未处理' }, { value: 'doing', label: '处理中' }, { value: 'done', label: '已完成' }] },
{ key: 'due_at', label: '到期', type: 'datetime-local', value: '' },
{ key: 'note', label: '备注', type: 'text', value: '' }
]
})
if (!form) return
await runAction('上传新文档', async log => {
log(`文件名: ${file.name}`)
const temp = await uploadTemp(file)
const created = await post('/api/doc_cloud_keeper/docs/create', {
folder_id: activeFolderId == null ? 0 : activeFolderId,
filename: file.name,
temp_name: temp.temp_name,
size_bytes: temp.size_bytes,
mime: file.type || 'application/octet-stream',
priority: form.priority,
status: form.status,
due_at: form.due_at || null,
note: form.note || ''
})
if (picked.handle && created && created.id) {
await saveDocHandle(created.id, picked.handle)
}
await refreshAll()
if (created && created.id && !docs.find(d => Number(d.id) === Number(created.id))) {
const ok = await waitBackendReady('后台处理中,正在等待上传结果同步', async () => {
await loadDocs()
return !!docs.find(d => Number(d.id) === Number(created.id))
}, { maxTry: 12, intervalMs: 400 })
if (!ok) log('等待超时:请稍后手动点击“刷新”')
}
}, '本地上传并创建文档记录')
}
const uploadCover = el('dck-upload-cover')
if (uploadCover) uploadCover.onclick = async () => {
const doc = ensureSelectedDoc()
const picked = await pickFile()
if (!picked || !picked.file) return
const file = picked.file
const yes = await askConfirm('覆盖上传', `将覆盖文档:${doc.filename}`, '确认覆盖')
if (!yes) return
await runAction('覆盖上传', async log => {
log(`文档ID: ${doc.id}`)
log(`覆盖文件: ${file.name}`)
const temp = await uploadTemp(file)
await post('/api/doc_cloud_keeper/docs/overwrite', {
id: doc.id,
filename: file.name || doc.filename,
temp_name: temp.temp_name,
size_bytes: temp.size_bytes,
mime: file.type || 'application/octet-stream',
priority: doc.priority,
status: doc.status,
due_at: doc.due_at || null,
note: doc.note || ''
})
if (picked.handle) await saveDocHandle(doc.id, picked.handle)
await refreshAll()
const ok = await waitBackendReady('后台处理中,正在等待覆盖结果同步', async () => {
const latest = await get(`/api/doc_cloud_keeper/docs/get?id=${encodeURIComponent(doc.id)}`)
const row = latest && latest.row ? latest.row : null
if (!row) return false
return Number(row.id) === Number(doc.id) && Number(row.size_bytes || 0) === Number(temp.size_bytes || 0)
}, { maxTry: 12, intervalMs: 400 })
if (!ok) log('等待超时:文档可能已写入,请手动刷新确认')
await refreshAll()
}, '保留旧版本并覆盖当前文档')
}
const download = el('dck-download')
if (download) download.onclick = async () => {
const doc = ensureSelectedDoc()
await runAction('下载文档', async log => {
log(`文档ID: ${doc.id}`)
const url = `/api/doc_cloud_keeper/docs/download?id=${encodeURIComponent(doc.id)}`
const out = await saveDownloadAs(doc, url)
if (out.mode === 'picker') {
log('已保存到固定目录(本地文件选择器)')
if (out.fileHandle) await saveDocHandle(doc.id, out.fileHandle)
setOpenFolderButton(true)
const yes = await askConfirm('下载成功', '是否立即尝试打开文件?', '立即打开')
if (yes) {
try {
const file = await out.fileHandle.getFile()
const localUrl = URL.createObjectURL(file)
window.open(localUrl, '_blank')
setTimeout(() => URL.revokeObjectURL(localUrl), 30000)
log('已尝试打开文件(由系统或浏览器决定打开方式)')
} catch (e) {
log(`打开失败: ${String(e && e.message ? e.message : e)}`)
}
}
} else {
log('当前环境不支持固定目录保存,已触发浏览器下载')
}
}, '下载到本地工作目录')
}
const rename = el('dck-rename')
if (rename) rename.onclick = async () => {
const doc = ensureSelectedDoc()
const form = await askForm({
title: '重命名文档',
message: `原文件名:${doc.filename}`,
confirmText: '保存',
fields: [{ key: 'filename', label: '新文件名', type: 'text', required: true, value: doc.filename || '' }]
})
if (!form) return
await runAction('重命名文档', async log => {
log(`文档ID: ${doc.id}`)
log(`新名称: ${form.filename}`)
await post('/api/doc_cloud_keeper/docs/rename', { id: doc.id, filename: form.filename })
await refreshAll()
}, '更新文档名称')
}
const move = el('dck-move')
if (move) move.onclick = async () => {
const doc = ensureSelectedDoc()
const form = await askForm({
title: '移动文档',
message: `文档:${doc.filename}`,
confirmText: '移动',
fields: [{ key: 'folder_id', label: '目标目录', type: 'select', value: String(doc.folder_id || 0), options: folderOptions().map(x => ({ value: String(x.value), label: x.label })) }]
})
if (!form) return
await runAction('移动文档', async log => {
log(`文档ID: ${doc.id}`)
log(`目标目录: ${form.folder_id}`)
await post('/api/doc_cloud_keeper/docs/move', { id: doc.id, folder_id: Number(form.folder_id || 0) })
await refreshAll()
}, '更新目录归属')
}
const metaEdit = el('dck-meta-edit')
if (metaEdit) metaEdit.onclick = async () => {
const doc = ensureSelectedDoc()
const form = await askForm({
title: '编辑文档属性',
message: `文档:${doc.filename}`,
confirmText: '保存属性',
fields: [
{ key: 'priority', label: '优先级', type: 'select', value: doc.priority || 'medium', options: [{ value: 'high', label: '高' }, { value: 'medium', label: '中' }, { value: 'low', label: '低' }] },
{ key: 'status', label: '状态', type: 'select', value: doc.status || 'todo', options: [{ value: 'todo', label: '未处理' }, { value: 'doing', label: '处理中' }, { value: 'done', label: '已完成' }] },
{ key: 'due_at', label: '到期', type: 'datetime-local', value: doc.due_at ? String(doc.due_at).slice(0, 16) : '' },
{ key: 'note', label: '备注', type: 'textarea', value: doc.note || '' }
]
})
if (!form) return
await runAction('更新文档属性', async log => {
log(`文档ID: ${doc.id}`)
await post('/api/doc_cloud_keeper/docs/update', {
id: doc.id,
priority: form.priority,
status: form.status,
due_at: form.due_at || null,
note: form.note || ''
})
await refreshAll()
}, '写入优先级、状态、到期、备注')
}
const versions = el('dck-versions')
if (versions) versions.onclick = async () => {
const doc = ensureSelectedDoc()
if (!doc) return
await openVersions(doc)
}
const versionsTabLocal = el('dck-versions-tab-local')
if (versionsTabLocal) versionsTabLocal.onclick = () => setVersionsTab('local')
const versionsTabOss = el('dck-versions-tab-oss')
if (versionsTabOss) versionsTabOss.onclick = () => setVersionsTab('oss')
const versionsCompare = el('dck-versions-compare')
if (versionsCompare) versionsCompare.onclick = runVersionCompare
const versionsOssRefresh = el('dck-versions-oss-refresh')
if (versionsOssRefresh) versionsOssRefresh.onclick = async () => {
const doc = ensureSelectedDoc()
await reloadOssVersions(doc)
}
const versionsClose = el('dck-versions-close')
if (versionsClose) versionsClose.onclick = () => show('dck-versions-modal', false)
const compareClose = el('dck-compare-close')
if (compareClose) compareClose.onclick = closeCompareModal
const previewClose = el('dck-preview-close')
if (previewClose) previewClose.onclick = closePreview
const previewCopy = el('dck-preview-copy')
if (previewCopy) previewCopy.onclick = async () => {
try { await copyText(previewContentText) } catch {}
}
const previewDownload = el('dck-preview-download')
if (previewDownload) previewDownload.onclick = () => {
if (!previewDocId) return
const doc = docs.find(d => Number(d.id) === Number(previewDocId))
if (!doc) return
window.open(`/api/doc_cloud_keeper/docs/download?id=${encodeURIComponent(previewDocId)}`, '_blank')
}
const trash = el('dck-trash')
if (trash) trash.onclick = async () => {
const doc = ensureSelectedDoc()
const yes = await askConfirm('逻辑删除', `将文档移入回收站:${doc.filename}`, '确认删除')
if (!yes) return
await runAction('逻辑删除文档', async log => {
log(`文档ID: ${doc.id}`)
await post('/api/doc_cloud_keeper/docs/trash', { id: doc.id })
await refreshAll()
}, '移入回收站')
}
const restore = el('dck-restore')
if (restore) restore.onclick = async () => {
const doc = ensureSelectedDoc()
const yes = await askConfirm('恢复文档', `恢复文档:${doc.filename}`, '确认恢复')
if (!yes) return
await runAction('恢复文档', async log => {
log(`文档ID: ${doc.id}`)
await post('/api/doc_cloud_keeper/docs/restore', { id: doc.id })
await refreshAll()
}, '从回收站恢复')
}
const hard = el('dck-hard-delete')
if (hard) hard.onclick = async () => {
const doc = ensureSelectedDoc()
const yes = await askConfirm('物理删除', `将永久删除:${doc.filename}`, '永久删除')
if (!yes) return
await runAction('物理删除文档', async log => {
log(`文档ID: ${doc.id}`)
await post('/api/doc_cloud_keeper/docs/trash_delete', { id: doc.id })
await refreshAll()
}, '删除数据库与文件实体')
}
const bulkTrash = el('dck-bulk-trash')
if (bulkTrash) bulkTrash.onclick = async () => {
const ids = Array.from(selectedIds)
if (!ids.length) throw new Error('请先勾选文档')
const yes = await askConfirm('批量逻辑删除', `将删除 ${ids.length} 个文档到回收站`, '确认批量删除')
if (!yes) return
await runAction('批量逻辑删除', async log => {
log(`数量: ${ids.length}`)
const out = await post('/api/doc_cloud_keeper/docs/bulk_trash', { ids })
log(`成功: ${(out.result && out.result.success && out.result.success.length) || 0}`)
log(`失败: ${(out.result && out.result.failed && out.result.failed.length) || 0}`)
selectedIds.clear()
await refreshAll()
}, '逐条执行并返回成功失败明细')
}
const bulkMove = el('dck-bulk-move')
if (bulkMove) bulkMove.onclick = async () => {
const ids = Array.from(selectedIds)
if (!ids.length) throw new Error('请先勾选文档')
const form = await askForm({
title: '批量移动文档',
message: `已选择 ${ids.length} 个文档`,
confirmText: '确认移动',
fields: [{ key: 'folder_id', label: '目标目录', type: 'select', value: '0', options: folderOptions().map(x => ({ value: String(x.value), label: x.label })) }]
})
if (!form) return
await runAction('批量移动文档', async log => {
log(`数量: ${ids.length}`)
log(`目标目录: ${form.folder_id}`)
const out = await post('/api/doc_cloud_keeper/docs/bulk_move', { ids, folder_id: Number(form.folder_id || 0) })
log(`成功: ${(out.result && out.result.success && out.result.success.length) || 0}`)
log(`失败: ${(out.result && out.result.failed && out.result.failed.length) || 0}`)
selectedIds.clear()
await refreshAll()
}, '逐条执行并返回成功失败明细')
}
const bulkHard = el('dck-bulk-hard-delete')
if (bulkHard) bulkHard.onclick = async () => {
const ids = Array.from(selectedIds)
if (!ids.length) throw new Error('请先勾选文档')
const yes = await askConfirm('批量物理删除', `将永久删除 ${ids.length} 个文档(不可恢复)`, '确认永久删除')
if (!yes) return
await runAction('批量物理删除', async log => {
log(`数量: ${ids.length}`)
const out = await post('/api/doc_cloud_keeper/docs/bulk_trash_delete', { ids })
log(`成功: ${(out.result && out.result.success && out.result.success.length) || 0}`)
log(`失败: ${(out.result && out.result.failed && out.result.failed.length) || 0}`)
selectedIds.clear()
await refreshAll()
}, '仅允许在回收站执行')
}
const changePass = el('dck-change-pass')
if (changePass) changePass.onclick = async () => {
const form = await askForm({
title: '修改密码',
message: '请输入旧密码和新密码',
confirmText: '确认修改',
fields: [
{ key: 'old_password', label: '旧密码', type: 'password', required: true },
{ key: 'new_password', label: '新密码', type: 'password', required: true }
]
})
if (!form) return
await runAction('修改登录密码', async log => {
log(`用户: ${me && me.username ? me.username : 'admin'}`)
await post('/api/doc_cloud_keeper/change_password', { old_password: form.old_password, new_password: form.new_password })
log('密码已更新')
}, '写入新的密码哈希')
}
}
const initLoginBackground = () => {
const loginContainer = el('dck-login')
if (!loginContainer) return
// 获取背景图片目录
const bgImages = [
'/home-bg/blue-sky-bf240d20/1.jpg',
'/home-bg/blue-sky-bf240d20/2.jpg',
'/home-bg/business-minimal-434facc4/1.jpg',
'/home-bg/business-minimal-434facc4/2.jpg',
'/home-bg/colorful-abstract-9b5b4d8c/1.jpg',
'/home-bg/colorful-abstract-9b5b4d8c/2.jpg',
'/home-bg/gradient-3d-9d6b3a3c/1.jpg',
'/home-bg/gradient-3d-9d6b3a3c/2.jpg',
'/home-bg/nature-landscape-8c0d5a7f/1.jpg',
'/home-bg/nature-landscape-8c0d5a7f/2.jpg',
'/home-bg/tech-futuristic-7a3b8c9d/1.jpg',
'/home-bg/tech-futuristic-7a3b8c9d/2.jpg',
'/home-bg/warm-minimal-5e4c3b2a/1.jpg',
'/home-bg/warm-minimal-5e4c3b2a/2.jpg'
]
// 随机选择一张背景图片
const randomIndex = Math.floor(Math.random() * bgImages.length)
const selectedBg = bgImages[randomIndex]
// 设置背景样式
loginContainer.style.background = `url('${selectedBg}') center/cover no-repeat`
loginContainer.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.3)'
loginContainer.style.border = 'none'
loginContainer.style.backdropFilter = 'blur(10px)'
loginContainer.style.color = 'white'
// 设置输入框样式
const inputs = loginContainer.querySelectorAll('.modal-input')
inputs.forEach(input => {
input.style.background = 'rgba(255, 255, 255, 0.9)'
input.style.border = '1px solid rgba(255, 255, 255, 0.3)'
input.style.color = '#333'
})
// 设置按钮样式
const loginBtn = el('dck-login-btn')
if (loginBtn) {
loginBtn.style.background = 'rgba(24, 144, 255, 0.9)'
loginBtn.style.border = 'none'
loginBtn.style.color = 'white'
}
}
const start = async () => {
try {
initLoginBackground()
const p = new URLSearchParams(window.location.search || '')
routeDocId = parseInt(String(p.get('doc_id') || '0'), 10) || 0
if (routeDocId) {
activeFolderId = null
trashMode = false
}
} catch {}
rememberedFsHandle = await loadRememberedHandle()
bind()
await ensureAuth()
startLiveSync()
setInterval(() => {
if (!docs || !docs.length) return
docs = applyDeadlineState(docs)
renderDocs()
}, 30000)
}
return { start }
})()
document.addEventListener('DOMContentLoaded', () => { dck.start() })