const cs = (() => { let me = null let currentId = 0 let currentVersionId = 0 let currentTab = 'list' let lastSavedPayload = '' let attachPanelOpen = false let isFullscreen = false let univerMode = 'none' let univerAdapter = null let pendingEditHint = false let actionDepth = 0 let actionHasError = false let actionTimer = null let actionCountdown = 0 let actionLogger = null let dialogResolver = null const themeKey = 'cloud_sheets_theme' const unsavedPromptEnabled = true const el = id => document.getElementById(id) const txt = (id, v) => { const n = el(id); if (n) n.textContent = v == null ? '' : String(v) } const show = (id, on) => { const n = el(id); if (n) n.style.display = on ? '' : 'none' } const esc = s => String(s || '').replace(/[&<>"]/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[m])) const status = (id, v) => { const n = el(id) if (!n) return if (!v) { n.style.display = 'none' n.textContent = '' return } n.style.display = '' n.textContent = String(v) } 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 node = el('cs-loading-status') if (!node) return node.className = `cs-loading-status${cls ? ` ${cls}` : ''}` node.textContent = String(text || '') } const openActionModal = (title, msg) => { clearActionTimer() actionCountdown = 0 actionHasError = false txt('cs-loading-title', title || '处理中') txt('cs-loading-msg', msg || '') setActionStatus('运行中', '') const logNode = el('cs-loading-logs') if (logNode) logNode.innerHTML = '' show('cs-loading', true) } const appendActionLog = (text, type) => { const logNode = el('cs-loading-logs') if (!logNode) return const line = document.createElement('div') line.className = `cs-log-line${type ? ` ${type}` : ''}` line.textContent = `[${nowStr()}] ${String(text || '')}` logNode.appendChild(line) logNode.scrollTop = logNode.scrollHeight } const closeActionModal = () => { clearActionTimer() show('cs-loading', 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 actionErrorText = err => { if (!err) return '未知错误' if (typeof err === 'string') return err const message = String(err.message || err) const stack = err && err.stack ? String(err.stack) : '' return stack ? `${message}\n${stack}` : message } 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 logger = line => appendActionLog(line) const prevLogger = actionLogger actionLogger = logger appendActionLog(`开始:${title}`) try { const result = await fn(logger) appendActionLog(`完成:${title}`, 'ok') return result } catch (err) { failAction(err) throw err } finally { actionLogger = prevLogger actionDepth -= 1 if (top && !actionHasError) finishActionSuccess() } } const openDialog = ({ title, message, buttons }) => { txt('cs-dialog-title', title || '提示') txt('cs-dialog-msg', message || '') const box = el('cs-dialog-actions') if (box) box.innerHTML = '' ;(Array.isArray(buttons) ? buttons : []).forEach(btn => { const b = document.createElement('button') b.className = 'mini-btn' b.textContent = String(btn.label || '确定') b.onclick = () => { show('cs-dialog', false) if (dialogResolver) { const r = dialogResolver dialogResolver = null r(btn.value) } } if (box) box.appendChild(b) }) show('cs-dialog', true) } const askDialog = options => new Promise(resolve => { dialogResolver = resolve openDialog(options || {}) }) const askConfirmModal = async (message, title) => { const out = await askDialog({ title: title || '确认操作', message: message || '', buttons: [ { label: '取消', value: false }, { label: '确定', value: true } ] }) return !!out } const showInfoModal = async (message, title) => { await askDialog({ title: title || '提示', message: message || '', buttons: [{ label: '确定', value: true }] }) } const currentTheme = () => document.body.getAttribute('data-theme') === 'light' ? 'light' : 'dark' const applyTheme = theme => { const t = theme === 'light' ? 'light' : 'dark' document.body.setAttribute('data-theme', t) try { localStorage.setItem(themeKey, t) } catch {} const btn = el('cs-toggle-theme') if (btn) btn.textContent = t === 'dark' ? '🌙' : '☀' } const getJsonEditor = () => el('cs-fallback-json') const makeDefaultWorkbook = () => ({ id: 'workbook-main', name: '云表格', appVersion: '1.0.0', locale: 'zhCN', styles: {}, sheetOrder: ['sheet-1'], sheets: { 'sheet-1': { id: 'sheet-1', name: 'Sheet1', tabColor: '', hidden: 0, rowCount: 1000, columnCount: 30, zoomRatio: 1, defaultColumnWidth: 80, defaultRowHeight: 24, cellData: { 0: { 0: { v: '证券记录' }, 1: { v: '金额' }, 2: { v: '备注' } }, 1: { 0: { v: '示例' }, 1: { v: 1000 }, 2: { v: '可编辑' } } } } } }) const toWorkbookString = v => { try { if (typeof v === 'string') { const s = v.trim() if (!s) return JSON.stringify(makeDefaultWorkbook()) JSON.parse(s) return s } return JSON.stringify(v && typeof v === 'object' ? v : makeDefaultWorkbook()) } catch { return JSON.stringify(makeDefaultWorkbook()) } } const previewTextFromWorkbook = workbookJson => { try { const text = JSON.stringify(typeof workbookJson === 'string' ? JSON.parse(workbookJson) : workbookJson || {}) .replace(/[{}[\]":,]/g, ' ') .replace(/\s+/g, ' ') .trim() if (!text) return '' return text.length > 260 ? `${text.slice(0, 260)}...` : text } catch { return '' } } const logLine = line => { if (typeof actionLogger === 'function') actionLogger(line) } const loadScript = src => new Promise((resolve, reject) => { const s = document.createElement('script') s.src = src s.async = true s.onload = () => resolve() s.onerror = () => reject(new Error(`脚本加载失败: ${src}`)) document.head.appendChild(s) }) const loadCss = href => new Promise((resolve, reject) => { const l = document.createElement('link') l.rel = 'stylesheet' l.href = href l.onload = () => resolve() l.onerror = () => reject(new Error(`样式加载失败: ${href}`)) document.head.appendChild(l) }) const ensureUniverAdapter = async () => { if (window.CloudSheetsUniverAdapter && typeof window.CloudSheetsUniverAdapter.create === 'function') return window.CloudSheetsUniverAdapter const cssCandidates = [ '/tools/cloud_sheets/vendor/univer/univer.css?v=3', '/tools/cloud_sheets/vendor/univer/adapter.css?v=13' ] for (const href of cssCandidates) { logLine(`加载样式: ${href}`) await loadCss(href) } const jsCandidates = [ '/tools/cloud_sheets/vendor/univer/adapter.js?v=13' ] for (const src of jsCandidates) { logLine(`加载脚本: ${src}`) await loadScript(src) if (window.CloudSheetsUniverAdapter && typeof window.CloudSheetsUniverAdapter.create === 'function') return window.CloudSheetsUniverAdapter } throw new Error('CloudSheetsUniverAdapter 未注入') } const initSheetEngine = async () => runAction('初始化表格引擎', async log => { const host = el('cs-univer') if (!host) throw new Error('缺少表格容器 cs-univer') // 如果已经初始化过了,不再重复创建,而是直接恢复显示 if (univerMode === 'univer' && univerAdapter) { log('Univer 实例已存在,直接恢复显示') show('cs-fallback-json', false) return } const adapter = await ensureUniverAdapter() log('准备创建 Univer 实例') // 强制显示 host 以便 Univer 能获取正确的高度 host.style.display = 'block' // 清空容器内旧元素防止重复挂载 host.innerHTML = '' log(`容器尺寸: 宽=${host.clientWidth}px, 高=${host.clientHeight}px`) univerAdapter = adapter.create({ container: host, locale: 'zh-CN', theme: currentTheme(), menuFull: true }) univerMode = 'univer' if (!univerAdapter || typeof univerAdapter.getWorkbookJSON !== 'function' || typeof univerAdapter.setWorkbookJSON !== 'function') { throw new Error('Univer 适配器接口不完整') } // 检查内部 DOM 是否生成 await new Promise(resolve => setTimeout(resolve, 600)) const appContainer = host.querySelector('.univer-app-container') if (appContainer) { log(`已挂载 .univer-app-container, 高度: ${appContainer.clientHeight}px`) } else { log(`警告: 未找到 .univer-app-container,尝试检查子节点`) log(`当前容器内子节点数: ${host.children.length}`) if (host.children.length > 0) { log(`第一个子节点: ${host.children[0].className} / ${host.children[0].tagName}`) } throw new Error('Univer 引擎未成功挂载到页面 DOM,请检查 Univer 初始化参数或版本兼容性。') } show('cs-fallback-json', false) log('Univer 表格控件创建成功') }, '本地化资源加载与控件创建') const hasActiveUniverRuntime = () => { const host = el('cs-univer') const hasMountedDom = !!(host && host.querySelector && host.querySelector('.univer-app-container')) const hasAdapterApi = !!(univerAdapter && typeof univerAdapter.getWorkbookJSON === 'function' && typeof univerAdapter.setWorkbookJSON === 'function') return hasAdapterApi || hasMountedDom || univerMode === 'univer' } const getWorkbookJson = () => { if (hasActiveUniverRuntime() && univerAdapter && typeof univerAdapter.getWorkbookJSON === 'function') { univerMode = 'univer' // 强制提交编辑态后再获取 JSON,确保脏状态的准确性 if (typeof univerAdapter.commitPendingChanges === 'function') { try { univerAdapter.commitPendingChanges() } catch {} } return toWorkbookString(univerAdapter.getWorkbookJSON()) } const ta = getJsonEditor() return toWorkbookString(ta ? ta.value : '') } const commitWorkbookPendingChanges = async (log) => { if (!hasActiveUniverRuntime()) { if (log) log('当前未检测到可用的 Univer 运行时,跳过编辑态提交') return } univerMode = 'univer' // Fallback: Dispatch global Enter key event to force exit editing mode just in case adapter method is not exposed try { const activeEl = document.activeElement if (activeEl) { if (typeof activeEl.blur === 'function') activeEl.blur() const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }) activeEl.dispatchEvent(enterEvent) } } catch {} if (!univerAdapter || typeof univerAdapter.commitPendingChanges !== 'function') { if (log) log('当前适配器不支持编辑态提交') return } const info = await univerAdapter.commitPendingChanges() if (!log) return if (!info || info.ok === false) { log(`提交表格编辑态失败: ${info && info.reason ? info.reason : '未知原因'}`) return } } const setWorkbookJson = raw => { const content = toWorkbookString(raw) if (hasActiveUniverRuntime() && univerAdapter && typeof univerAdapter.setWorkbookJSON === 'function') { univerMode = 'univer' try { univerAdapter.setWorkbookJSON(content) } catch (e) { log(`设置表格数据失败: ${e.message}`) throw e } return } const ta = getJsonEditor() if (ta) ta.value = content } const stableCellSignature = cell => { if (!cell || typeof cell !== 'object') return null const out = {} if (Object.prototype.hasOwnProperty.call(cell, 'v')) out.v = cell.v if (Object.prototype.hasOwnProperty.call(cell, 'f')) out.f = cell.f if (Object.prototype.hasOwnProperty.call(cell, 'm')) out.m = cell.m if (Object.keys(out).length === 0) return null if (out.v == null && (!out.f || !String(out.f).trim()) && (!out.m || !String(out.m).trim())) return null if (typeof out.v === 'string' && !out.v.trim() && (!out.f || !String(out.f).trim()) && (!out.m || !String(out.m).trim())) return null return out } const buildWorkbookContentSignature = workbookJson => { try { const wb = typeof workbookJson === 'string' ? JSON.parse(workbookJson) : workbookJson if (!wb || typeof wb !== 'object' || !wb.sheets || typeof wb.sheets !== 'object') return { sheets: [] } const sheetIds = Object.keys(wb.sheets).sort() const sheets = [] sheetIds.forEach(sid => { const sheet = wb.sheets[sid] if (!sheet || typeof sheet !== 'object') return const rows = sheet.cellData && typeof sheet.cellData === 'object' ? sheet.cellData : {} const cells = {} Object.keys(rows).sort((a, b) => Number(a) - Number(b)).forEach(r => { const cols = rows[r] if (!cols || typeof cols !== 'object') return Object.keys(cols).sort((a, b) => Number(a) - Number(b)).forEach(c => { const sig = stableCellSignature(cols[c]) if (sig) cells[`${r}:${c}`] = sig }) }) sheets.push({ id: sid, name: String(sheet.name || ''), cells }) }) return { sheets } } catch { return { sheets: [] } } } const buildPayloadSignature = (title, workbookJson) => JSON.stringify({ title: String(title == null ? '' : title), workbook: buildWorkbookContentSignature(workbookJson) }) const markPendingEdit = () => { pendingEditHint = true } const clearPendingEdit = () => { pendingEditHint = false } const isEditableMutationKey = e => { if (!e) return false if (e.ctrlKey || e.metaKey || e.altKey) return false const key = String(e.key || '') if (!key) return false if (key.length === 1) return true return key === 'Backspace' || key === 'Delete' || key === 'Enter' || key === 'Tab' } const isEventInsideNode = (event, node) => { if (!event || !node) return false const target = event.target return !!(target && node.contains && node.contains(target)) } const isPotentialUniverEditEvent = event => { const host = el('cs-univer') if (!host) return false if (isEventInsideNode(event, host)) return true const activeEl = document.activeElement return !!(activeEl && host.contains && host.contains(activeEl)) } const buildCurrentPayload = () => buildPayloadSignature( String((el('cs-title') && el('cs-title').value) || ''), getWorkbookJson() ) const hasUnsavedChanges = options => { const opts = options || {} if (buildCurrentPayload() !== lastSavedPayload) return true return !!opts.includePendingEdit && pendingEditHint } const workbookHasVisibleContent = workbookJson => { try { const wb = typeof workbookJson === 'string' ? JSON.parse(workbookJson) : workbookJson if (!wb || typeof wb !== 'object' || !wb.sheets || typeof wb.sheets !== 'object') return false for (const sid of Object.keys(wb.sheets)) { const sheet = wb.sheets[sid] if (!sheet || !sheet.cellData || typeof sheet.cellData !== 'object') continue const rows = sheet.cellData for (const r of Object.keys(rows)) { const cols = rows[r] if (!cols || typeof cols !== 'object') continue for (const c of Object.keys(cols)) { const cell = cols[c] if (!cell || typeof cell !== 'object' || !Object.prototype.hasOwnProperty.call(cell, 'v')) continue const v = cell.v if (v == null) continue if (typeof v === 'string') { if (v.trim()) return true } else { return true } } } } return false } catch { return false } } const shouldPromptUnsaved = () => { if (!unsavedPromptEnabled) return false if (!hasUnsavedChanges({ includePendingEdit: true })) return false const title = String((el('cs-title') && el('cs-title').value) || '').trim() const workbookJson = getWorkbookJson() if (!title && !workbookHasVisibleContent(workbookJson)) return false return true } const confirmLeaveIfUnsaved = async actionLabel => { try { await commitWorkbookPendingChanges() } catch {} if (!shouldPromptUnsaved()) return true return askConfirmModal(`你有未保存内容,确认继续${actionLabel}吗?`, '未保存提醒') } const get = async url => { const fullUrl = (() => { const sep = url.includes('?') ? '&' : '?' return `${url}${sep}_ts=${Date.now()}` })() logLine(`请求 GET ${fullUrl}`) const r = await fetch(fullUrl, { cache: 'no-store' }) logLine(`响应状态: ${r.status}`) const data = await r.json() if (!r.ok || (data && data.ok === false)) throw new Error(`请求失败 ${fullUrl}: ${data && data.error ? 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)) { if (r.status === 409) return { status: r.status, data } throw new Error(`请求失败 ${url}: ${data && data.error ? data.error : r.status}`) } return { status: r.status, data } } const renderList = rows => { const ul = el('cs-list') if (!ul) return ul.innerHTML = '' const arr = Array.isArray(rows) ? rows : [] arr.forEach(r => { const li = document.createElement('li') li.dataset.id = String(r.id || '') const flags = [] if (r.starred) flags.push('⭐') if (r.pinned) flags.push('📌') li.innerHTML = `
${esc(r.title || '')}
${esc(String(r.updated_at || ''))} ${flags.join(' ')}
` li.onclick = () => selectSheet(r.id) if (String(currentId) === String(r.id)) li.classList.add('active') ul.appendChild(li) }) } const loadListCore = async () => { const endpoint = currentTab === 'trash' ? '/api/cloud_sheets/sheet/trash_list' : '/api/cloud_sheets/sheet/list' const j = await get(endpoint) renderList(Array.isArray(j.rows) ? j.rows : []) } const loadList = async withModal => { if (withModal === false) return loadListCore() return runAction('加载表格列表', async log => { log(currentTab === 'trash' ? '当前视图: 回收站' : '当前视图: 列表') await loadListCore() log('列表渲染完成') }) } const markActive = id => { const ul = el('cs-list') if (!ul) return ul.querySelectorAll('li').forEach(li => li.classList.toggle('active', String(li.dataset.id || '') === String(id || ''))) } const renderAttachments = rows => { const box = el('cs-attachments') if (!box) return box.innerHTML = '' const list = Array.isArray(rows) ? rows : [] if (!list.length) return list.forEach(r => { const row = document.createElement('div') row.className = 'cs-attach-row' const left = document.createElement('a') left.href = r.file_path left.target = '_blank' left.textContent = r.filename || '' const right = document.createElement('button') right.className = 'mini-btn' right.textContent = '删除' right.onclick = async () => { await runAction('删除附件', async log => { log(`附件ID: ${r.id}`) await fetch(`/api/cloud_sheets/attachments/${encodeURIComponent(r.id)}`, { method: 'DELETE' }) await loadAttachments(currentId, false) log('附件删除并刷新完成') }) } row.appendChild(left) row.appendChild(right) box.appendChild(row) }) } const loadAttachmentsCore = async sheetId => { if (!sheetId) { renderAttachments([]) return } const j = await get(`/api/cloud_sheets/attachments?sheet_id=${encodeURIComponent(sheetId)}`) renderAttachments(j.rows || []) } const loadAttachments = async (sheetId, withModal) => { if (withModal === false) return loadAttachmentsCore(sheetId) return runAction('加载附件', async log => { log(`表格ID: ${sheetId}`) await loadAttachmentsCore(sheetId) log('附件渲染完成') }) } const showHomeView = () => { const hv = el('cs-home-view') const ev = el('cs-editor-view') if (hv) hv.style.display = 'flex' if (ev) ev.style.display = 'none' } const showEditorView = () => { const hv = el('cs-home-view') const ev = el('cs-editor-view') if (hv) hv.style.display = 'none' if (ev) ev.style.display = 'flex' } const selectSheetCore = async id => { const j = await get(`/api/cloud_sheets/sheet/get?id=${encodeURIComponent(id)}`) if (j && j.row) { currentId = j.row.id currentVersionId = Number(j.row.version_id || 0) if (el('cs-title')) el('cs-title').value = j.row.title || '' setWorkbookJson(j.row.workbook_json || '') lastSavedPayload = buildPayloadSignature(String(j.row.title || ''), toWorkbookString(j.row.workbook_json || '')) clearPendingEdit() markActive(currentId) await loadAttachmentsCore(currentId) showEditorView() } } const selectSheet = async id => { if (!id) return return runAction('打开表格', async log => { log(`目标ID: ${id}`) if (!(await confirmLeaveIfUnsaved('切换表格'))) { log('用户取消切换,保留当前编辑') return } if (shouldPromptUnsaved()) log('检测到未保存改动,用户选择继续切换') await selectSheetCore(id) log('表格内容写入编辑器完成') }, `ID=${id}`) } const saveSheetCore = async (force, log) => { if (log) log(`运行时检测: univerMode=${univerMode}, hasRuntime=${hasActiveUniverRuntime() ? '是' : '否'}`) // 先让原生焦点离开输入控件 if (document.activeElement && typeof document.activeElement.blur === 'function') { try { document.activeElement.blur() } catch {} } await new Promise(resolve => setTimeout(resolve, 50)) // 再调用 Univer 自带的“结束编辑并保存”API,确保当前正在输入的单元格真正提交进数据模型 await commitWorkbookPendingChanges(log) await new Promise(resolve => setTimeout(resolve, 120)) const title = String((el('cs-title') && el('cs-title').value) || '').trim() || '未命名表格' const workbook_json = getWorkbookJson() if (log) { log(`获取到底层表格数据: ${typeof workbook_json === 'string' ? workbook_json.length + ' 字符' : Object.keys(workbook_json).length + ' 个键'}`) if (typeof workbook_json === 'string') { try { const parsed = JSON.parse(workbook_json) let sheetCount = 0 if (parsed.sheets) { for (const key in parsed.sheets) { sheetCount++ const sheet = parsed.sheets[key] const a1 = sheet.cellData && sheet.cellData['0'] && sheet.cellData['0']['0'] log(`[${sheet.name}] A1: ${JSON.stringify(a1)}`) } } log(`共找到 ${sheetCount} 个 Sheet 快照`) } catch(e) { log(`解析快照失败: ${e.message}`) } } } const preview_text = previewTextFromWorkbook(workbook_json) if (log) log(`请求保存,force=${!!force}`) const { status: httpStatus, data } = await post('/api/cloud_sheets/sheet/save', { id: currentId || 0, title, workbook_json, preview_text, base_version_id: currentVersionId || 0, force: !!force }) if (httpStatus === 409 || (data && data.conflict)) return { conflict: true, data } if (data && data.ok) { if (log) log(`保存成功,返回ID: ${data.id}, 版本: ${data.version_id}`) currentId = data.id || currentId currentVersionId = Number(data.version_id || currentVersionId || 0) lastSavedPayload = buildPayloadSignature(title, toWorkbookString(workbook_json)) clearPendingEdit() await loadListCore() markActive(currentId) await loadAttachmentsCore(currentId) } else { if (log) log(`保存失败,返回值异常`) } return { conflict: false, data } } const saveSheet = async force => runAction('保存表格', async log => { log(`当前ID: ${currentId || 0}`) const out = await saveSheetCore(force, log) if (out.conflict) { log('检测到版本冲突') const go = await askConfirmModal('检测到版本冲突,是否覆盖保存?', '版本冲突') if (!go) throw new Error('用户取消覆盖保存') log('用户确认覆盖保存') await saveSheetCore(true, log) } log('保存成功并刷新列表') }, '写入数据库并生成版本') const newSheet = async () => runAction('新建表格', async log => { if (!(await confirmLeaveIfUnsaved('新建表格'))) { log('用户取消新建,保留当前编辑') return } if (shouldPromptUnsaved()) log('检测到未保存改动,用户选择继续新建') currentId = 0 currentVersionId = 0 if (el('cs-title')) el('cs-title').value = '' setWorkbookJson(makeDefaultWorkbook()) lastSavedPayload = buildCurrentPayload() clearPendingEdit() markActive(0) renderAttachments([]) showEditorView() log('已创建空白表格并重置状态') }) const cloneSheet = async () => { if (!currentId) { await showInfoModal('请先选择一个表格', '无法克隆') return } return runAction('克隆表格', async log => { const title = String((el('cs-title') && el('cs-title').value) || '').trim() || '未命名表格' const d = new Date() const cloneTitle = `${title}-复制${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` const workbook_json = getWorkbookJson() const preview_text = previewTextFromWorkbook(workbook_json) log(`新标题: ${cloneTitle}`) const { data } = await post('/api/cloud_sheets/sheet/save', { id: 0, title: cloneTitle, workbook_json, preview_text }) if (!data || !data.ok || !data.id) throw new Error('克隆保存失败') await post('/api/cloud_sheets/sheet/clone_attachments', { src_id: currentId, dest_id: data.id }) currentId = data.id currentVersionId = Number(data.version_id || 0) if (el('cs-title')) el('cs-title').value = cloneTitle lastSavedPayload = buildPayloadSignature(cloneTitle, toWorkbookString(workbook_json)) clearPendingEdit() await loadListCore() markActive(currentId) await loadAttachmentsCore(currentId) log('克隆完成') }) } const toggleStar = async () => { if (!currentId) return return runAction('切换加星', async log => { const li = document.querySelector(`.cs-list li[data-id="${currentId}"]`) const isStar = !!(li && li.textContent && li.textContent.includes('⭐')) await post('/api/cloud_sheets/sheet/star', { id: currentId, starred: !isStar }) await loadListCore() markActive(currentId) log(`结果: ${!isStar ? '已加星' : '已取消加星'}`) }) } const togglePin = async () => { if (!currentId) return return runAction('切换置顶', async log => { const li = document.querySelector(`.cs-list li[data-id="${currentId}"]`) const isPin = !!(li && li.textContent && li.textContent.includes('📌')) await post('/api/cloud_sheets/sheet/pin', { id: currentId, pinned: !isPin }) await loadListCore() markActive(currentId) log(`结果: ${!isPin ? '已置顶' : '已取消置顶'}`) }) } const softDelete = async () => { if (!currentId) return const ok = await askConfirmModal('删除后进入回收站,确认继续吗?', '确认删除') if (!ok) return return runAction('删除到回收站', async log => { await post('/api/cloud_sheets/sheet/trash', { id: currentId }) currentId = 0 currentVersionId = 0 if (el('cs-title')) el('cs-title').value = '' setWorkbookJson(makeDefaultWorkbook()) lastSavedPayload = buildCurrentPayload() clearPendingEdit() markActive(0) renderAttachments([]) await loadListCore() log('已删除并刷新列表') }) } const restoreOrHardDeleteFromTrash = async id => { if (!id) return return runAction('回收站操作', async log => { const choice = await askDialog({ title: '回收站操作', message: '请选择要执行的操作:', buttons: [ { label: '取消', value: 'cancel' }, { label: '还原', value: 'restore' }, { label: '彻底删除', value: 'hard' } ] }) if (choice === 'hard') { await post('/api/cloud_sheets/sheet/trash_delete', { id }) log('执行彻底删除') } else if (choice === 'restore') { await post('/api/cloud_sheets/sheet/restore', { id }) log('执行还原') } else { log('用户取消回收站操作') return } await loadListCore() log('回收站列表刷新完成') }) } const openVersions = async () => { if (!currentId) return return runAction('加载版本列表', async log => { const j = await get(`/api/cloud_sheets/sheet/versions?id=${encodeURIComponent(currentId)}`) const box = el('cs-versions-list') if (!box) throw new Error('缺少版本列表容器') const versions = Array.isArray(j.versions) ? j.versions : [] box.innerHTML = '' versions.forEach(v => { const row = document.createElement('div') row.className = 'cs-version-row' const l = document.createElement('div') l.textContent = `${v.saved_at || ''} · ${v.title || ''}` const r = document.createElement('button') r.className = 'mini-btn' r.textContent = '恢复到编辑器' r.onclick = () => { if (el('cs-title')) el('cs-title').value = v.title || '' setWorkbookJson(v.workbook_json || '') currentVersionId = Number(v.id || currentVersionId || 0) clearPendingEdit() show('cs-versions-modal', false) } row.appendChild(l) row.appendChild(r) box.appendChild(row) }) show('cs-versions-modal', true) log(`版本数: ${versions.length}`) }) } const doSearch = async () => runAction('搜索表格', async log => { const input = el('cs-search-input') if (!input) throw new Error('缺少搜索输入框') const q = String(input.value || '').trim() if (!q) { if (el('cs-search-results')) el('cs-search-results').innerHTML = '' log('关键字为空,已清空结果') return } const j = await get(`/api/cloud_sheets/sheet/search?q=${encodeURIComponent(q)}`) const rows = Array.isArray(j.rows) ? j.rows : [] const box = el('cs-search-results') if (!box) throw new Error('缺少搜索结果容器') box.innerHTML = '' rows.forEach(r => { const item = document.createElement('div') item.className = 'cs-search-item' item.innerHTML = `
${esc(r.title || '')}
${esc((r.preview_text || '').slice(0, 120))}
` item.onclick = async () => { show('cs-search-layer', false) await selectSheet(r.id) } box.appendChild(item) }) log(`命中条数: ${rows.length}`) }) const attachFile = async file => { if (!currentId) { await showInfoModal('请先保存表格再上传附件', '无法上传') return } return runAction('上传附件', async log => { log(`文件名: ${file.name || ''}`) const r = await fetch(`/api/cloud_sheets/attachment?sheet_id=${encodeURIComponent(currentId)}&filename=${encodeURIComponent(file.name)}`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file }) const j = await r.json() if (!r.ok || !j || j.ok === false) throw new Error(`上传失败: ${j && j.error ? j.error : r.status}`) await loadAttachmentsCore(currentId) log('上传完成并刷新附件列表') }) } const ensureAuth = async () => { let authOk = false try { const j = await get('/api/cloud_sheets/me') if (j && j.ok) { me = j.user || null authOk = true } } catch (e) { console.warn('Auth check failed', e) } if (authOk) { show('cs-login', false) show('cs-app', true) showHomeView() try { await initSheetEngine() } catch (err) { // 如果引擎加载失败,不退出登录,保留界面以便查看错误日志 } try { await loadList() } catch {} return } show('cs-app', false) show('cs-login', true) } const initLoginBackground = () => { const loginContainer = el('cs-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('cs-login-btn') if (loginBtn) { loginBtn.style.background = 'rgba(24, 144, 255, 0.9)' loginBtn.style.border = 'none' loginBtn.style.color = 'white' } } const login = async () => { const username = String((el('cs-username') && el('cs-username').value) || '').trim() const password = String((el('cs-password') && el('cs-password').value) || '') if (!username || !password) { status('cs-login-status', '请输入用户名和密码') return } try { const loginBtn = el('cs-login-btn') if (loginBtn) loginBtn.disabled = true await runAction('登录云表格', async log => { log(`用户名: ${username}`) const { data } = await post('/api/cloud_sheets/login', { username, password }) if (!data || !data.ok) throw new Error('登录失败') status('cs-login-status', '') await ensureAuth() }) } catch { status('cs-login-status', '登录失败') show('cs-login-status', true) } finally { const loginBtn = el('cs-login-btn') if (loginBtn) loginBtn.disabled = false } } const logout = async () => { try { await runAction('退出登录', async () => { await post('/api/cloud_sheets/logout', {}) me = null show('cs-app', false) show('cs-login', true) }) } catch {} } const toggleTrashTab = async () => { currentTab = currentTab === 'trash' ? 'list' : 'trash' txt('cs-trash-tab', currentTab === 'trash' ? '返回列表' : '回收站') await loadList() } const toggleAttachPanel = () => { attachPanelOpen = !attachPanelOpen show('cs-attach-panel', attachPanelOpen) } const toggleFullscreen = () => { isFullscreen = !isFullscreen document.body.classList.toggle('cs-fullscreen', isFullscreen) txt('cs-fullscreen', isFullscreen ? '缩屏' : '全屏') } const bind = () => { const tbtn = el('cs-toggle-theme') if (tbtn) tbtn.onclick = () => applyTheme(currentTheme() === 'dark' ? 'light' : 'dark') const loginBtn = el('cs-login-btn') if (loginBtn) loginBtn.onclick = login ;(async function tryAutoFill() { try { const token = localStorage.getItem('toolbox_creds_token') || '' const url = '/api/cloud_sheets/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('cs-username').value = d.creds.username if (d.creds.password) el('cs-password').value = d.creds.password } } catch {} el('cs-username').removeAttribute('readonly') el('cs-password').removeAttribute('readonly') })() const pwd = el('cs-password') if (pwd) pwd.addEventListener('keydown', e => { if (e.key === 'Enter') login() }) const logoutBtn = el('cs-logout-btn') if (logoutBtn) logoutBtn.onclick = logout const newBtn = el('cs-new') if (newBtn) newBtn.onclick = () => { newSheet() } const refreshBtn = el('cs-refresh-list') if (refreshBtn) refreshBtn.onclick = () => { loadList() } const saveBtn = el('cs-save') if (saveBtn) saveBtn.onclick = () => { saveSheet(false) } const titleInput = el('cs-title') if (titleInput) { titleInput.addEventListener('input', markPendingEdit) titleInput.addEventListener('change', markPendingEdit) titleInput.addEventListener('paste', markPendingEdit) } const cloneBtn = el('cs-clone') if (cloneBtn) cloneBtn.onclick = () => { cloneSheet() } const starBtn = el('cs-star') if (starBtn) starBtn.onclick = () => { toggleStar() } const pinBtn = el('cs-pin') if (pinBtn) pinBtn.onclick = () => { togglePin() } const delBtn = el('cs-delete') if (delBtn) delBtn.onclick = () => { softDelete() } const trashBtn = el('cs-trash-tab') if (trashBtn) trashBtn.onclick = () => { toggleTrashTab() } const fullBtn = el('cs-fullscreen') if (fullBtn) fullBtn.onclick = toggleFullscreen const attachToggle = el('cs-attach-toggle') if (attachToggle) attachToggle.onclick = toggleAttachPanel const attachBtn = el('cs-attach-btn') if (attachBtn) attachBtn.onclick = () => { const n = el('cs-attach-file'); if (n) n.click() } const attachInput = el('cs-attach-file') if (attachInput) attachInput.onchange = e => { const f = e.target.files && e.target.files[0]; if (f) attachFile(f) } const searchOpen = el('cs-search-open') if (searchOpen) searchOpen.onclick = () => show('cs-search-layer', true) const searchClose = el('cs-search-close') if (searchClose) searchClose.onclick = () => show('cs-search-layer', false) const searchInput = el('cs-search-input') if (searchInput) searchInput.onkeydown = e => { if (e.key === 'Enter') doSearch() } const versionsBtn = el('cs-version-btn') if (versionsBtn) versionsBtn.onclick = () => { openVersions() } const versionsClose = el('cs-versions-close') if (versionsClose) versionsClose.onclick = () => show('cs-versions-modal', false) const loadingClose = el('cs-loading-close') if (loadingClose) loadingClose.onclick = closeActionModal const loadingCopy = el('cs-loading-copy') if (loadingCopy) loadingCopy.onclick = () => { const logs = el('cs-loading-logs') if (logs) { const text = Array.from(logs.querySelectorAll('.cs-log-line')).map(n => n.textContent).join('\n') navigator.clipboard.writeText(text).then(() => { const orig = loadingCopy.textContent loadingCopy.textContent = '已复制!' setTimeout(() => { loadingCopy.textContent = orig }, 2000) }).catch(async err => { await showInfoModal('复制失败: ' + err.message, '复制失败') }) } } const dialogClose = el('cs-dialog-close') if (dialogClose) dialogClose.onclick = () => { show('cs-dialog', false) if (dialogResolver) { const r = dialogResolver dialogResolver = null r(false) } } // 增加重载引擎按钮,方便调试容器状态 const reloadBtn = document.createElement('button') reloadBtn.textContent = '重载引擎' reloadBtn.onclick = async () => { univerMode = 'none' if (univerAdapter && univerAdapter.dispose) { try { univerAdapter.dispose() } catch {} } univerAdapter = null await initSheetEngine() } const actionsBox = document.querySelector('.cs-right-header .actions') if (actionsBox) actionsBox.appendChild(reloadBtn) window.addEventListener('keydown', e => { const k = String(e.key || '').toLowerCase() if ((e.ctrlKey || e.metaKey) && k === 's') { e.preventDefault() saveSheet(false) } }) window.addEventListener('beforeinput', e => { if (isPotentialUniverEditEvent(e)) markPendingEdit() }, true) window.addEventListener('input', e => { if (isPotentialUniverEditEvent(e)) markPendingEdit() }, true) window.addEventListener('compositionend', e => { if (isPotentialUniverEditEvent(e)) markPendingEdit() }, true) window.addEventListener('paste', e => { if (isPotentialUniverEditEvent(e)) markPendingEdit() }, true) window.addEventListener('cut', e => { if (isPotentialUniverEditEvent(e)) markPendingEdit() }, true) window.addEventListener('drop', e => { if (isPotentialUniverEditEvent(e)) markPendingEdit() }, true) window.addEventListener('keydown', e => { if (isPotentialUniverEditEvent(e) && isEditableMutationKey(e)) markPendingEdit() }, true) window.addEventListener('beforeunload', e => { if (!shouldPromptUnsaved()) return e.preventDefault() e.returnValue = '' }) const list = el('cs-list') if (list) list.addEventListener('contextmenu', async e => { const li = e.target && e.target.closest ? e.target.closest('li[data-id]') : null if (!li) return if (currentTab !== 'trash') return e.preventDefault() const id = parseInt(String(li.dataset.id || '0'), 10) || 0 await restoreOrHardDeleteFromTrash(id) }) } const start = async () => { try { const saved = localStorage.getItem(themeKey) if (saved === 'light' || saved === 'dark') applyTheme(saved) else applyTheme('dark') } catch {} bind() initLoginBackground() await ensureAuth() } return { start } })() document.addEventListener('DOMContentLoaded', () => { cs.start() })