Files
Toolbox/public/tools/cloud_sheets/cloud_sheets.js
T

1221 lines
44 KiB
JavaScript

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 = `<div class="name">${esc(r.title || '')}</div><div class="meta">${esc(String(r.updated_at || ''))} ${flags.join(' ')}</div>`
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 = `<div>${esc(r.title || '')}</div><div>${esc((r.preview_text || '').slice(0, 120))}</div>`
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()
})