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 += `` }) html += '' rows.forEach(row => { html += '' headers.forEach((_, i) => { html += `` }) html += '' }) html += '
${esc(h)}
${esc(row[i] == null ? '' : String(row[i]))}
' 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 `` } 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 = `
${esc(v.title)}
文件: ${esc(v.filename)} | 大小: ${formatSize(v.size_bytes)} | 时间: ${esc(v.created_at || '')}
${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 url = '/api/doc_cloud_keeper/auth/local_creds' + (token ? '?token=' + encodeURIComponent(token) : '') const r = await fetch(url, { 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() })