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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 = ``
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 = `
预览加载失败:${(data && data.error) || `HTTP ${resp.status}`}
`
return
}
const p = data.preview
if (p.mode === 'text') {
previewContentText = p.content || ''
if (copyBtn) copyBtn.style.display = ''
if (content) content.innerHTML = `${esc(previewContentText)}`
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 = `工作表:${esc(t.sheet_name || 'Sheet1')} | 总行数:${t.total_rows || 0}${t.truncated ? ' | 仅显示前 100 行' : ''}
`
html += ''
headers.forEach(h => { html += `| ${esc(h)} | ` })
html += '
'
rows.forEach(row => {
html += ''
headers.forEach((_, i) => { html += `| ${esc(row[i] == null ? '' : String(row[i]))} | ` })
html += '
'
})
html += '
'
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 += `第 ${i + 1} 页
${esc(s.title || '无标题')}
`
if (s.body) html += `
${esc(s.body)}`
html += '
'
})
if (!html) html = '未提取到幻灯片内容
'
if (content) content.innerHTML = html
return
}
if (p.mode === 'unsupported') {
if (content) content.innerHTML = `${esc(p.message || '该文件类型暂不支持在线预览')}
`
if (copyBtn) copyBtn.style.display = 'none'
return
}
if (content) content.innerHTML = '未知预览模式
'
} catch (e) {
if (loading) loading.style.display = 'none'
if (content) content.innerHTML = `预览异常:${esc(actionErrorText(e))}
`
}
}
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 = `全部文档 (${allCount})
`
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 = ''.repeat(depth)
const count = computeCount(f.id)
li.innerHTML = `
${spacer}${esc(f.name)} (${count})
`
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 = `
${esc(r.filename)}
${parentFolderName ? `${esc(parentFolderName)}` : ''}
`
tr.innerHTML = `
|
|
${filenameHtml} |
${esc(r.ext || '')} |
${formatSize(r.size_bytes)} |
${r.priority === 'high' ? '高' : r.priority === 'low' ? '低' : '中'} |
${r.status === 'todo' ? '未处理' : r.status === 'doing' ? '处理中' : '已完成'} |
${esc(r.due_at || '-')} |
${countdownText(r.deadline_ms)} |
${esc(r.updated_at || '')} |
`
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, '
'), '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}
原始对象地址: ${objectUrl}
下载地址(24小时): ${downloadUrl}
ETag: ${esc(result.etag || '-')}
RequestId: ${esc(result.request_id || '-')}
HEAD RequestId: ${esc(result.head_request_id || '-')}
SHA256: ${esc(result.sha256 || '-')}
`.trim().replace(/\n/g, '
'), 'ok')
} catch (err) {
const errText = actionErrorText(err)
appendOssProbeLog(`检测异常: ${errText}`, 'err')
setOssProbeResult(`状态: 失败\n错误: ${esc(errText)}`.replace(/\n/g, '
'), '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 '暂无数据
'
return `${list.map(item => `- ${esc(item)}
`).join('')}
`
}
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 = ['']
.concat(changedSheets.map(item => ``))
.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 `
工作表统计
${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}`
])}
工作表筛选
${changedSheetItems.length ? `按表变化数量
${renderCompareListHtml(changedSheetItems)}
` : ''}
`
}
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 = `
左侧版本
${renderCompareListHtml([
`标签:${(cmp.left && cmp.left.label) || '-'}`,
`文件:${(cmp.left && cmp.left.filename) || '-'}`,
`大小:${formatSize(cmp.left && cmp.left.size_bytes)}`,
`时间:${(cmp.left && cmp.left.created_at) || '-'}`
])}
右侧版本
${renderCompareListHtml([
`标签:${(cmp.right && cmp.right.label) || '-'}`,
`文件:${(cmp.right && cmp.right.filename) || '-'}`,
`大小:${formatSize(cmp.right && cmp.right.size_bytes)}`,
`时间:${(cmp.right && cmp.right.created_at) || '-'}`
])}
${warnings.length ? `提示
${renderCompareListHtml(warnings)}
` : ''}
`
const summaryHtml = `${cmp.file_type === 'excel' ? excelStatsSummaryHtml(cmp) : ''}${renderCompareListHtml(cmp.summary_items || [])}
`
const detailsHtml = sections.length
? sections.map(section => `${esc(section.title || '明细')}
${renderCompareListHtml(section.items || [])}
`).join('')
: ''
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 = `
待比较版本
${renderCompareListHtml([`左侧:${left.label}`, `右侧:${right.label}`])}
`
if (summary) summary.innerHTML = '正在分析文件结构与内容差异,请稍候...
'
if (details) details.innerHTML = '第一版会返回摘要和关键明细。
'
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 = `${esc(errText)}
`
if (details) details.innerHTML = '比较已中断,请确认文件类型和版本文件是否完整。
'
}
}
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 = '暂无可比较版本
'
return
}
items.forEach(v => {
const checked = isVersionSelected(v) ? 'checked' : ''
const actionButtons = v.target_type === 'current'
? ``
: ``
const row = document.createElement('div')
row.className = 'dck-version-row'
row.innerHTML = `
${actionButtons}
`
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 = '暂无 OSS 备份记录
'
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 ? '【物理删除】' : ''
const retryInfo = status === 'failed' && v.last_error
? `失败原因: ${esc(v.last_error)}
`
: ''
const row = document.createElement('div')
row.className = 'dck-version-row'
row.innerHTML = `
${title}
${ossStatusText(status)}
${deletedMark}
文件: ${esc(v.filename)} | 大小: ${formatSize(v.size_bytes)} | 记录时间: ${esc(v.created_at || '')}${v.last_uploaded_at ? ` | 上传时间: ${esc(v.last_uploaded_at)}` : ''}
${retryInfo}
`
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() })