6dc79a2c12
- 私人剪贴板新增限时直通URL生成功能 - doc_cloud_keeper新增标题复制按钮 - 新增贪蛇出动游戏工具 - 新增OSS中转直链工具 - 添加批量抽帧测试脚本
2125 lines
86 KiB
JavaScript
2125 lines
86 KiB
JavaScript
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 = `<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>
|
||
<button type="button" class="dck-copy-title-btn" data-doc-id="${r.id}" title="复制标题">📋</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)
|
||
}
|
||
})
|
||
Array.from(tb.querySelectorAll('.dck-copy-title-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
|
||
try {
|
||
await copyText(doc.filename || '')
|
||
btn.textContent = '✅'
|
||
setTimeout(() => { btn.textContent = '📋' }, 1200)
|
||
} catch (err) {
|
||
btn.textContent = '⚠️'
|
||
setTimeout(() => { btn.textContent = '📋' }, 1200)
|
||
}
|
||
}
|
||
})
|
||
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() })
|