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

2327 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const cn = (() => {
let me = null
let currentId = 0
let currentTab = 'notes'
let allTags = []
let settingsTags = []
let currentNoteTagIds = []
let activeTagId = 0
let tagsLoaded = false
let tinyToolbarSettings = null
const tinyToolbarOptions = [
{ key: 'undo_redo', label: '撤销/重做', buttons: 'undo redo' },
{ key: 'blocks_fontsize', label: '段落与字号', buttons: 'blocks fontsize' },
{ key: 'inline_format', label: '基础格式(加粗/颜色等)', buttons: 'bold italic underline strikethrough forecolor backcolor removeformat' },
{ key: 'align', label: '对齐方式', buttons: 'alignleft aligncenter alignright alignjustify' },
{ key: 'indent', label: '缩进', buttons: 'outdent indent' },
{ key: 'lists', label: '项目符号与清单', buttons: 'bullist numlist checklist' },
{ key: 'table', label: '表格', buttons: 'table' },
{ key: 'media', label: '链接与多媒体', buttons: 'link image media' },
{ key: 'insert_misc', label: '分割线/时间等插入', buttons: 'emoticons charmap hr insertdatetime' },
{ key: 'code', label: '源码与代码块', buttons: 'code codesample' },
{ key: 'page_tools', label: '分页与锚点', buttons: 'pagebreak nonbreaking anchor' },
{ key: 'search_visual', label: '查找与可视化辅助', buttons: 'searchreplace visualblocks visualchars' },
{ key: 'preview_fullscreen_print', label: '预览/全屏/打印', buttons: 'preview fullscreen print' },
{ key: 'save_note', label: '保存笔记', buttons: 'cnSave' },
{ key: 'cn_tools', label: '附件与版本', buttons: 'cnAttach cnVersion' },
{ key: 'menubar', label: '顶部菜单栏', buttons: '' },
{ key: 'upgrade_button', label: '右上角 Upgrade 按钮', buttons: '' }
]
const tinyToolbarDefaultSettings = () => {
const s = {}
tinyToolbarOptions.forEach(opt => {
s[opt.key] = true
})
return s
}
const normalizeTinyToolbarSettings = raw => {
const def = tinyToolbarDefaultSettings()
const out = {}
Object.keys(def).forEach(k => {
out[k] = raw && Object.prototype.hasOwnProperty.call(raw, k) ? !!raw[k] : def[k]
})
return out
}
const buildEditorToolbar = () => {
const current = normalizeTinyToolbarSettings(tinyToolbarSettings)
const parts = []
tinyToolbarOptions.forEach(opt => {
if (current[opt.key] && opt.buttons) parts.push(opt.buttons)
})
return parts.join(' | ')
}
const applyEditorUpgradeVisibility = (editor, showUpgrade) => {
try {
const container = editor && editor.getContainer ? editor.getContainer() : null
if (!container) return
const nodes = container.querySelectorAll('button, a, span, div')
nodes.forEach(node => {
const text = (node.textContent || '').trim()
const title = (node.getAttribute && node.getAttribute('title')) ? node.getAttribute('title').trim() : ''
const aria = (node.getAttribute && node.getAttribute('aria-label')) ? node.getAttribute('aria-label').trim() : ''
if (text === 'Upgrade' || title === 'Upgrade' || aria === 'Upgrade') {
node.style.display = showUpgrade ? '' : 'none'
}
})
} catch {}
}
const el = id => document.getElementById(id)
const show = (id, v) => { const e = el(id); if (e) e.style.display = v ? '' : 'none' }
const txt = (id, v) => { const e = el(id); if (e) e.value = v }
const html = (id, v) => { const e = el(id); if (e) e.innerHTML = v }
const status = (id, s) => { const e = el(id); if (!e) return; e.textContent = s; e.style.display = s ? '' : 'none' }
let loading = null
let t0 = 0
const nowMs = () => Date.now()
const themeKey = 'cloud_notes_theme'
const idleKey = 'cloud_notes_idle_minutes'
const attachPanelKey = 'cloud_notes_attach_panel_open'
let attachPanelOpen = false
let rightView = 'dashboard'
let dashboardData = null
let isFullscreen = false
let fullscreenResizeBound = false
let idleMinutes = 0
let idleTimer = null
let idleEventsBound = false
let locked = false
let currentVersionId = 0
let versionsCache = []
let conflictLatest = null
let compareLeftId = 0
let compareRightId = 0
const showLoading = (title, first) => {
t0 = nowMs()
if (loading && loading.parentNode) loading.parentNode.removeChild(loading)
const m = document.createElement('div')
m.className = 'modal'
const body = document.createElement('div')
body.className = 'modal-body'
const head = document.createElement('div')
head.style.display = 'flex'
head.style.justifyContent = 'space-between'
head.style.alignItems = 'center'
const h = document.createElement('div')
h.textContent = title || '处理中'
const spinner = document.createElement('div')
spinner.className = 'cn-spinner'
head.appendChild(h)
head.appendChild(spinner)
const box = document.createElement('div')
box.className = 'loading-log'
box.textContent = '正在处理,请稍候...'
const acts = document.createElement('div')
acts.className = 'modal-actions'
const copyBtn = document.createElement('button')
copyBtn.textContent = '复制日志'
copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(box.textContent || '') } catch {} }
const closeBtn = document.createElement('button')
closeBtn.textContent = '关闭'
closeBtn.onclick = () => { if (m && m.parentNode) m.parentNode.removeChild(m) }
acts.appendChild(copyBtn)
acts.appendChild(closeBtn)
body.appendChild(head)
body.appendChild(box)
body.appendChild(acts)
m.appendChild(body)
document.body.appendChild(m)
loading = m
if (first) appendLoading(first)
}
const appendLoading = s => {
try {
const box = loading && loading.querySelector('.loading-log')
const delta = nowMs() - t0
const line = `[+${delta}ms] ${String(s || '')}`
if (box) box.textContent = (box.textContent ? (box.textContent + '\n') : '') + line
} catch {}
}
const hideLoading = () => {
try { if (loading && loading.parentNode) loading.parentNode.removeChild(loading) } catch {}
loading = null
}
const confirmModal = (title, message) => new Promise(resolve => {
const m = document.createElement('div')
m.className = 'modal'
const body = document.createElement('div')
body.className = 'modal-body'
const head = document.createElement('div')
head.style.marginBottom = '8px'
head.textContent = title || '确认操作'
const msg = document.createElement('div')
msg.textContent = message || ''
msg.style.marginBottom = '12px'
const acts = document.createElement('div')
acts.className = 'modal-actions'
const ok = document.createElement('button')
ok.textContent = '确定'
ok.onclick = () => { if (m && m.parentNode) m.parentNode.removeChild(m); resolve(true) }
const cancel = document.createElement('button')
cancel.textContent = '取消'
cancel.onclick = () => { if (m && m.parentNode) m.parentNode.removeChild(m); resolve(false) }
acts.appendChild(ok)
acts.appendChild(cancel)
body.appendChild(head)
body.appendChild(msg)
body.appendChild(acts)
m.appendChild(body)
document.body.appendChild(m)
})
const escapeHtml = s => String(s || '').replace(/[&<>"']/g, c => {
if (c === '&') return '&amp;'
if (c === '<') return '&lt;'
if (c === '>') return '&gt;'
if (c === '"') return '&quot;'
return '&#39;'
})
let lastSavedHtml = ''
const normalizeHtmlForDirty = s => String(s || '').trim()
const getEditorHtmlNormalized = () => {
try {
if (!window.tinymce || !tinymce.activeEditor) return ''
return normalizeHtmlForDirty(tinymce.activeEditor.getContent())
} catch {
return ''
}
}
const hasUnsavedEditorChanges = () => {
const current = getEditorHtmlNormalized()
const saved = normalizeHtmlForDirty(lastSavedHtml)
return current !== saved
}
const ensureCanLeaveNote = async () => {
if (!hasUnsavedEditorChanges()) return true
const ok = await confirmModal('提示', '当前笔记内容已修改但尚未保存,确定要离开并放弃未保存的修改吗?')
return ok
}
const post = async (url, body) => {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), cache: 'no-store' })
if (!r.ok) throw new Error('network_error')
return r.json()
}
const get = async url => {
let full = url
try {
const u = new URL(url, window.location.origin)
u.searchParams.set('_ts', String(Date.now()))
full = u.toString()
} catch {}
const r = await fetch(full, { cache: 'no-store' })
if (!r.ok) throw new Error('network_error')
return r.json()
}
const loadScriptOnce = url => new Promise((resolve, reject) => {
try {
const s = document.createElement('script')
s.src = url
s.async = true
s.onload = () => resolve()
s.onerror = e => reject(new Error('load_error'))
document.head.appendChild(s)
} catch (e) { reject(e) }
})
const loadScriptWithTimeout = (url, timeoutMs) => new Promise((resolve, reject) => {
try {
let done = false
const s = document.createElement('script')
s.src = url
s.async = true
const clear = () => { if (t) { clearTimeout(t); t = null } }
s.onload = () => { if (done) return; done = true; clear(); resolve() }
s.onerror = () => { if (done) return; done = true; clear(); reject(new Error('load_error')) }
let t = setTimeout(() => { if (done) return; done = true; clear(); reject(new Error('timeout')) }, Math.max(500, timeoutMs||3000))
document.head.appendChild(s)
} catch (e) { reject(e) }
})
const ensureTinyMCE = async () => {
showLoading('加载编辑器', '准备加载 TinyMCE')
if (!window.tinymce) {
const urls = [
'/tools/cloud_notes/tinymce/tinymce.min.js',
'https://断绝看看本地是不是可以cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.3/tinymce.min.js',
'https://断绝看看本地是不是可以cdn.jsdelivr.net/npm/tinymce@6.8.3/tinymce.min.js'
]
let loaded = false
for (const u of urls) {
appendLoading(`尝试: ${u}`)
try {
await loadScriptWithTimeout(u, 2000)
if (window.tinymce) {
loaded = true
appendLoading('加载成功')
break
}
} catch (e) { appendLoading(`失败: ${String(e && e.message || 'error')}`) }
}
if (!loaded) {
appendLoading('TinyMCE CDN 加载失败')
hideLoading()
throw new Error('tinymce_load_failed')
}
} else {
appendLoading('TinyMCE 已存在,跳过主库加载')
}
const langUrls = [
'/tools/cloud_notes/tinymce/language/zh_CN.js',
'https://cdn.jsdelivr.net/npm/tinymce-i18n@23.10.9/langs6/zh_CN.min.js'
]
let langLoaded = false
for (const lu of langUrls) {
appendLoading(`加载中文包: ${lu}`)
try { await loadScriptWithTimeout(lu, 1000); appendLoading('中文包加载完成'); langLoaded = true; break } catch (e) { appendLoading(`中文包失败: ${String(e && e.message || 'error')}`) }
}
if (!langLoaded) appendLoading('中文包未能加载,界面可能显示英文')
}
const computeWeekTitle = () => {
const d = new Date()
const yyyy = d.getFullYear()
const isoWeek = (() => {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
const day = (t.getUTCDay() + 6) % 7
t.setUTCDate(t.getUTCDate() - day + 3)
const firstThu = new Date(Date.UTC(t.getUTCFullYear(), 0, 4))
const week = Math.ceil(((t - firstThu) / 86400000 + 1) / 7)
return week
})()
return `${yyyy}-第${isoWeek}周(自动新增)`
}
const currentTheme = () => {
const v = document.body.getAttribute('data-theme')
return v === 'light' ? 'light' : 'dark'
}
const applyTheme = theme => {
const t = theme === 'light' ? 'light' : 'dark'
const b = document.body
b.setAttribute('data-theme', t)
const btn = el('cn-toggle-theme')
if (btn) {
btn.textContent = t === 'dark' ? '🌙' : '☀'
const title = t === 'dark' ? '深色/浅色切换' : '浅色/深色切换'
btn.title = title
btn.setAttribute('aria-label', title)
}
try { localStorage.setItem(themeKey, t) } catch {}
}
const logout = async () => {
try {
await fetch('/api/cloud_notes/logout', { method: 'POST' })
} catch {}
}
const lockForIdle = () => {
idleTimer = null
locked = true
logout()
show('cn-app', false)
show('cn-login', true)
status('cn-login-status', '已因长时间无操作锁定,请重新登录')
}
const resetIdleTimer = () => {
if (!idleMinutes) return
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => lockForIdle(), idleMinutes * 60000)
}
const applyIdleMinutes = m => {
idleMinutes = m > 0 ? m : 0
try {
if (idleMinutes) localStorage.setItem(idleKey, String(idleMinutes))
else localStorage.removeItem(idleKey)
} catch {}
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null }
if (idleMinutes) resetIdleTimer()
}
const onUserActivity = () => {
if (!idleMinutes) return
resetIdleTimer()
}
const bindIdleEvents = () => {
if (idleEventsBound) return
idleEventsBound = true
;['click', 'keydown', 'mousemove', 'touchstart'].forEach(ev => {
window.addEventListener(ev, onUserActivity, true)
})
}
const initPreferences = () => {
let theme = currentTheme()
try {
const saved = localStorage.getItem(themeKey)
if (saved === 'light' || saved === 'dark') theme = saved
} catch {}
applyTheme(theme)
let m = 0
try {
const raw = localStorage.getItem(idleKey)
const v = parseInt(String(raw || '0'), 10)
if (Number.isFinite(v) && v > 0) m = Math.min(v, 720)
} catch {}
applyIdleMinutes(m)
try {
const rawAttach = localStorage.getItem(attachPanelKey)
attachPanelOpen = rawAttach === '1'
} catch {}
}
const isLocked = () => locked || !me
const ensureUnlocked = () => {
if (isLocked()) {
window.alert('已锁定,请先登录')
return false
}
return true
}
const initEditor = () => {
appendLoading('开始初始化 TinyMCE 编辑器')
const theme = currentTheme()
const skin = theme === 'light' ? 'oxide' : 'oxide-dark'
const contentCss = theme === 'light' ? 'default' : 'dark'
const currentToolbar = normalizeTinyToolbarSettings(tinyToolbarSettings)
const menubarEnabled = !!currentToolbar.menubar
const showUpgrade = !!currentToolbar.upgrade_button
tinymce.init({
selector: '#cn-editor',
language: 'zh_CN',
language_url: '/tools/cloud_notes/tinymce/language/zh_CN.js',
plugins: 'lists advlist link image media table code codesample emoticons charmap hr insertdatetime preview searchreplace visualblocks visualchars fullscreen print anchor pagebreak nonbreaking help wordcount',
toolbar: buildEditorToolbar(),
menubar: menubarEnabled,
branding: false,
height: 560,
content_css: contentCss,
skin,
toolbar_mode: 'wrap',
font_size_formats: '12px 14px 16px 18px 20px 24px 28px 32px',
automatic_uploads: true,
file_picker_types: 'image',
file_picker_callback: async (cb, value, meta) => {
try {
const inp = document.createElement('input')
inp.type = 'file'
inp.accept = 'image/*'
inp.onchange = async () => {
const file = inp.files && inp.files[0]
if (!file) return
try {
showLoading('上传图片', '开始准备数据')
appendLoading(`文件名: ${file.name}`)
const r = await fetch(`/api/cloud_notes/upload/image?filename=${encodeURIComponent(file.name)}`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file })
if (!r.ok) throw new Error('upload_error')
const j = await r.json()
if (!j.ok || !j.path) throw new Error('bad_resp')
appendLoading(`上传成功: ${j.path}`)
hideLoading()
cb(j.path, { title: file.name })
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
inp.click()
} catch {}
},
setup: editor => {
editor.on('init', () => {
try {
const I18n = tinymce.util && tinymce.util.I18n
const code = I18n && I18n.getCode ? I18n.getCode() : ''
const settingsLang = editor && editor.settings && editor.settings.language
const data = tinymce.i18n && tinymce.i18n.data
const keys = data && typeof data === 'object' ? Object.keys(data) : []
const sample = keys.slice(0, 5).join(', ')
appendLoading(`TinyMCE settings.language = ${settingsLang || '(空)'}`)
appendLoading(`TinyMCE I18n.getCode() = ${code || '(空)'}`)
appendLoading(`TinyMCE 已注册语言 key: ${sample || '(无)'}${keys.length > 5 ? ' ...' : ''}`)
} catch (e) {
appendLoading(`检测语言失败: ${String(e && e.message || e)}`)
}
hideLoading()
applyEditorUpgradeVisibility(editor, showUpgrade)
})
editor.on('keydown', e => {
const k = String(e.key || '').toLowerCase()
if ((e.ctrlKey || e.metaKey) && k === 's') {
if (e.preventDefault) e.preventDefault()
if (e.stopPropagation) e.stopPropagation()
if (!ensureUnlocked()) return
saveNote()
}
})
editor.addShortcut('meta+s', '保存笔记', () => { if (!ensureUnlocked()) return; saveNote() })
editor.addShortcut('ctrl+s', '保存笔记', () => { if (!ensureUnlocked()) return; saveNote() })
editor.ui.registry.addButton('h1', { text: 'H1', tooltip: '一级标题', onAction: () => editor.execCommand('FormatBlock', false, 'h1') })
editor.ui.registry.addButton('h2', { text: 'H2', tooltip: '二级标题', onAction: () => editor.execCommand('FormatBlock', false, 'h2') })
editor.ui.registry.addButton('cnSave', { text: '保存', tooltip: '保存笔记', onAction: () => { if (!ensureUnlocked()) return; saveNote() } })
editor.ui.registry.addButton('cnAttach', { text: '附件', tooltip: '插入附件', onAction: () => { const f = el('cn-attach-file'); if (f) f.click() } })
editor.ui.registry.addButton('cnVersion', { text: '版本', tooltip: '查看版本', onAction: () => listVersions() })
},
images_upload_handler: async (blobInfo, success, failure) => {
try {
showLoading('上传图片', '开始准备数据')
const name = blobInfo.filename()
appendLoading(`文件名: ${name}`)
const r = await fetch(`/api/cloud_notes/upload/image?filename=${encodeURIComponent(name)}`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: blobInfo.blob() })
if (!r.ok) throw new Error('upload_error')
const j = await r.json()
if (!j.ok || !j.path) throw new Error('bad_resp')
appendLoading(`上传成功: ${j.path}`)
hideLoading()
success(j.path)
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); failure(String(e && e.message || 'error')); hideLoading() }
}
}).then(editors => {
const editor = editors && editors[0]
if (!editor) return
const resize = () => {
const container = editor.getContainer()
if (!container) return
if (isFullscreen) {
onFullscreenResize()
return
}
const rect = container.getBoundingClientRect()
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 800
const extra = attachPanelOpen && currentId ? 220 : 40
const h = Math.max(300, viewportHeight - rect.top - extra)
container.style.height = h + 'px'
const iframe = container.querySelector('iframe')
if (iframe) iframe.style.height = (h - 80) + 'px'
}
resize()
window.addEventListener('resize', resize)
editor.on('focus', resize)
editor.on('NodeChange', resize)
editor.on('FullscreenStateChanged', resize)
editor.cnResizeForLayout = resize
})
}
const pickSafeColor = c => {
const s = String(c || '').trim()
if (/^#([0-9a-fA-F]{3}){1,2}$/.test(s)) return s
return '#2563eb'
}
const setCurrentNoteTags = ids => {
const arr = Array.isArray(ids) ? ids : []
const list = []
const seen = new Set()
arr.forEach(x => {
const v = parseInt(String(x || 0), 10)
if (Number.isFinite(v) && v > 0 && !seen.has(v)) {
seen.add(v)
list.push(v)
}
})
currentNoteTagIds = list
renderNoteTagChips()
}
const renderNoteTagChips = () => {
const box = el('cn-note-tag-chips')
if (!box) return
box.innerHTML = ''
if (!currentNoteTagIds.length || !allTags.length) {
const span = document.createElement('span')
span.className = 'cn-tag-empty'
span.textContent = '未设置标签'
box.appendChild(span)
return
}
currentNoteTagIds.forEach(id => {
const tag = allTags.find(t => String(t.id || '') === String(id || ''))
if (!tag) return
const chip = document.createElement('div')
chip.className = 'cn-tag-chip'
const dot = document.createElement('span')
dot.className = 'dot'
if (tag.color) dot.style.background = pickSafeColor(tag.color)
const label = document.createElement('span')
label.className = 'label'
label.textContent = tag.name || ''
const close = document.createElement('span')
close.className = 'close'
close.textContent = '✕'
close.title = '移除标签'
close.onclick = e => {
e.stopPropagation()
currentNoteTagIds = currentNoteTagIds.filter(x => String(x || '') !== String(id || ''))
renderNoteTagChips()
}
chip.appendChild(dot)
chip.appendChild(label)
chip.appendChild(close)
box.appendChild(chip)
})
}
const closeTagPicker = () => {
const picker = el('cn-note-tag-picker')
if (!picker) return
picker.style.display = 'none'
picker.innerHTML = ''
}
const renderTagPickerMenu = () => {
const picker = el('cn-note-tag-picker')
if (!picker) return
const available = allTags.filter(t => !currentNoteTagIds.some(id => String(id || '') === String(t.id || '')))
if (!available.length) {
picker.innerHTML = ''
picker.style.display = 'none'
return
}
const menu = document.createElement('div')
menu.className = 'cn-tag-picker-menu'
available.forEach(tag => {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'cn-tag-picker-item'
const dot = document.createElement('span')
dot.className = 'dot'
if (tag.color) dot.style.background = pickSafeColor(tag.color)
const label = document.createElement('span')
label.textContent = tag.name || ''
btn.appendChild(dot)
btn.appendChild(label)
btn.onclick = () => {
const v = parseInt(String(tag.id || 0), 10)
if (Number.isFinite(v) && v > 0 && !currentNoteTagIds.some(x => String(x || '') === String(v || ''))) {
currentNoteTagIds = currentNoteTagIds.concat([v])
renderNoteTagChips()
}
closeTagPicker()
}
menu.appendChild(btn)
})
picker.innerHTML = ''
picker.appendChild(menu)
}
const openTagPicker = () => {
const picker = el('cn-note-tag-picker')
if (!picker) return
if (!allTags.length) {
window.alert('请先在右上角“设置-标签设置”中新增标签')
return
}
renderTagPickerMenu()
if (!picker.innerHTML) return
picker.style.display = ''
}
const toggleTagPicker = () => {
const picker = el('cn-note-tag-picker')
if (!picker) return
const visible = picker.style.display !== 'none'
if (visible) closeTagPicker()
else openTagPicker()
}
const renderSettingsTags = () => {
const box = el('cn-settings-tags-list')
if (!box) return
box.innerHTML = ''
if (!settingsTags.length) {
const empty = document.createElement('div')
empty.className = 'cn-tag-settings-empty'
empty.textContent = '暂无标签,点击下方“新增标签”添加'
box.appendChild(empty)
return
}
settingsTags.forEach((t, idx) => {
const row = document.createElement('div')
row.className = 'cn-tag-settings-row'
const nameInput = document.createElement('input')
nameInput.type = 'text'
nameInput.className = 'modal-input cn-tag-settings-name'
nameInput.placeholder = '标签名称'
nameInput.value = t.name || ''
nameInput.oninput = e => {
t.name = e.target.value
}
const colorWrap = document.createElement('div')
colorWrap.className = 'cn-tag-settings-color'
const colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.value = pickSafeColor(t.color)
t.color = colorInput.value
colorInput.oninput = e => {
t.color = e.target.value
colorText.textContent = e.target.value
}
const colorText = document.createElement('span')
colorText.style.marginLeft = '6px'
colorText.textContent = t.color || ''
colorWrap.appendChild(colorInput)
colorWrap.appendChild(colorText)
const delBtn = document.createElement('button')
delBtn.type = 'button'
delBtn.className = 'mini-btn cn-tag-settings-del'
delBtn.textContent = '删除'
delBtn.onclick = () => {
settingsTags.splice(idx, 1)
renderSettingsTags()
}
row.appendChild(nameInput)
row.appendChild(colorWrap)
row.appendChild(delBtn)
box.appendChild(row)
})
}
const renderTinyToolbarSettings = () => {
const box = el('cn-settings-tiny-toolbar-list')
if (!box) return
box.innerHTML = ''
const current = normalizeTinyToolbarSettings(tinyToolbarSettings)
tinyToolbarSettings = current
tinyToolbarOptions.forEach(opt => {
const row = document.createElement('div')
row.className = 'cn-tag-settings-row'
const name = document.createElement('div')
name.className = 'cn-tag-settings-name'
name.textContent = opt.label
const state = document.createElement('div')
state.className = 'cn-tag-settings-color'
state.textContent = current[opt.key] ? '已显示' : '已隐藏'
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'mini-btn cn-tag-settings-del'
btn.textContent = current[opt.key] ? '关闭' : '开启'
btn.onclick = () => {
const next = Object.assign({}, current)
next[opt.key] = !next[opt.key]
tinyToolbarSettings = next
renderTinyToolbarSettings()
}
row.appendChild(name)
row.appendChild(state)
row.appendChild(btn)
box.appendChild(row)
})
}
const renderTagFilterBar = () => {
const box = el('cn-tags-filter')
if (!box) return
if (currentTab !== 'tags') {
box.style.display = 'none'
box.innerHTML = ''
return
}
if (!allTags.length) {
box.style.display = ''
box.innerHTML = ''
const empty = document.createElement('div')
empty.className = 'cn-tag-settings-empty'
empty.textContent = '暂无标签,可在右上角“设置-标签设置”中添加'
box.appendChild(empty)
return
}
box.style.display = ''
const frag = document.createDocumentFragment()
const allBtn = document.createElement('button')
allBtn.type = 'button'
allBtn.className = 'cn-tag-filter-btn'
if (!activeTagId) allBtn.classList.add('active')
allBtn.textContent = '全部'
allBtn.onclick = () => {
activeTagId = 0
loadList()
}
frag.appendChild(allBtn)
allTags.forEach(tag => {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'cn-tag-filter-btn'
if (String(activeTagId || '') === String(tag.id || '')) btn.classList.add('active')
const dot = document.createElement('span')
dot.className = 'dot'
if (tag.color) dot.style.background = pickSafeColor(tag.color)
const label = document.createElement('span')
label.textContent = tag.name || ''
btn.appendChild(dot)
btn.appendChild(label)
btn.onclick = () => {
activeTagId = tag.id
loadList()
}
frag.appendChild(btn)
})
box.innerHTML = ''
box.appendChild(frag)
}
const reloadTags = async () => {
try {
const j = await get('/api/cloud_notes/tags/list')
const rows = Array.isArray(j.rows) ? j.rows : []
allTags = rows.map(r => ({
id: r.id,
name: r.name || '',
color: r.color || '',
sort_index: typeof r.sort_index === 'number' ? r.sort_index : 0
}))
settingsTags = allTags.map(t => ({ id: t.id, name: t.name, color: t.color, sort_index: t.sort_index }))
tagsLoaded = true
currentNoteTagIds = currentNoteTagIds.filter(id => allTags.some(t => String(t.id || '') === String(id || '')))
if (!allTags.some(t => String(t.id || '') === String(activeTagId || ''))) activeTagId = 0
renderSettingsTags()
renderTagFilterBar()
renderNoteTagChips()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`) }
}
const loadTinyToolbarSettings = async () => {
try {
const j = await get('/api/cloud_notes/settings/tiny_toolbar')
const raw = j && j.settings ? j.settings : {}
tinyToolbarSettings = normalizeTinyToolbarSettings(raw)
} catch {
tinyToolbarSettings = tinyToolbarDefaultSettings()
}
renderTinyToolbarSettings()
}
const ensureAuth = async () => {
try {
const j = await get('/api/cloud_notes/me')
if (j && j.ok) {
me = j.user
locked = false
show('cn-login', false)
show('cn-app', true)
syncSettingsUI()
await loadTinyToolbarSettings()
await ensureTinyMCE()
initEditor()
await reloadTags()
await loadList()
await loadDashboard()
setRightView('dashboard')
updateAttachmentVisibility()
} else throw new Error('unauthorized')
} catch {
me = null
locked = true
show('cn-login', true)
show('cn-app', false)
}
}
const login = async () => {
try {
status('cn-login-status', '')
const username = el('cn-username').value.trim()
const password = el('cn-password').value
const btn = el('cn-login-btn'); if (btn) btn.disabled = true
showLoading('登录', '准备发送请求')
appendLoading(`接口: /api/cloud_notes/login`)
appendLoading(`用户名: ${username || '(空)'}`)
const j = await post('/api/cloud_notes/login', { username, password })
if (j && j.ok) {
appendLoading(`登录成功,用户: ${j.user && j.user.username ? j.user.username : '未知'}`)
appendLoading('开始加载主页数据')
const pwdInput = el('cn-password')
if (pwdInput) pwdInput.value = ''
await ensureAuth()
appendLoading('主页加载完成')
hideLoading()
} else {
appendLoading('登录失败: unauthorized')
status('cn-login-status', '登录失败')
hideLoading()
}
} catch (e) { appendLoading(`网络错误: ${String(e && e.message || 'error')}`); status('cn-login-status', '网络错误'); hideLoading() }
finally { const btn2 = el('cn-login-btn'); if (btn2) btn2.disabled = false }
}
const newNote = () => {
currentId = 0
currentVersionId = 0
txt('cn-title', computeWeekTitle())
if (tinymce.activeEditor) tinymce.activeEditor.setContent('')
lastSavedHtml = getEditorHtmlNormalized()
html('cn-attachments', '')
setCurrentNoteTags([])
closeTagPicker()
markActive(0)
updateAttachmentVisibility()
}
const updateAttachmentVisibility = () => {
const panel = el('cn-attach-panel')
const btn = el('cn-attach-toggle')
const enabled = !!currentId
const open = enabled && attachPanelOpen && !isFullscreen
if (panel) panel.style.display = open ? 'block' : 'none'
if (btn) {
btn.disabled = !enabled
const label = enabled && attachPanelOpen ? '收缩附件' : '展开附件'
btn.textContent = label
btn.title = label
btn.setAttribute('aria-label', label)
}
try {
if (tinymce && tinymce.activeEditor && tinymce.activeEditor.cnResizeForLayout) {
tinymce.activeEditor.cnResizeForLayout()
}
} catch {}
}
const toggleAttachPanel = () => {
if (!currentId) return
attachPanelOpen = !attachPanelOpen
try {
localStorage.setItem(attachPanelKey, attachPanelOpen ? '1' : '0')
} catch {}
updateAttachmentVisibility()
}
const isCloneTitle = title => /-复制\d{4}-\d{2}-\d{2}$/.test(String(title || '').trim())
const confirmCloneSave = async title => {
if (!isCloneTitle(title)) return true
return confirmModal('克隆笔记提醒', '当前笔记为“复制/克隆”备份,确认继续保存到复制版吗?')
}
const saveNote = async () => {
try {
const title = el('cn-title').value.trim() || '未命名'
if (!(await confirmCloneSave(title))) return
showLoading('保存笔记', '准备提交内容')
const htmlContent = tinymce.activeEditor ? tinymce.activeEditor.getContent() : ''
const body = { id: currentId || 0, title, html: htmlContent, user_id: me && me.id ? me.id : 1, tag_ids: currentNoteTagIds.slice(), base_version_id: currentVersionId || 0 }
appendLoading(`标题: ${title}`)
const r = await fetch('/api/cloud_notes/note/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store' })
let j = null
try { j = await r.json() } catch {}
if (r.status === 409 || (j && j.conflict)) {
conflictLatest = j && j.latest ? j.latest : null
hideLoading()
openConflictModal()
return
}
if (j && j.ok) {
currentId = j.id
if (j.version_id) currentVersionId = Number(j.version_id || 0)
lastSavedHtml = normalizeHtmlForDirty(htmlContent)
updateAttachmentVisibility()
appendLoading(`保存成功 id=${currentId}`)
status('cn-login-status', '已保存')
await loadNotesList()
markActive(currentId)
await loadAttachments(currentId)
hideLoading()
} else {
appendLoading('保存失败')
status('cn-login-status', '保存失败')
hideLoading()
}
} catch { status('cn-login-status', '网络错误'); hideLoading() }
}
const cloneCurrentNote = async () => {
if (!ensureUnlocked()) return
if (!currentId) {
await confirmModal('提示', '请先选择要克隆的笔记')
return
}
try {
const srcId = currentId
const titleInput = el('cn-title')
const rawTitle = titleInput ? titleInput.value.trim() : ''
const baseTitle = rawTitle || '未命名'
const d = new Date()
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const newTitle = `${baseTitle}-复制${yyyy}-${mm}-${dd}`
const htmlContent = tinymce.activeEditor ? tinymce.activeEditor.getContent() : ''
showLoading('克隆笔记', `来源 id=${srcId}`)
appendLoading(`新标题: ${newTitle}`)
const body = { id: 0, title: newTitle, html: htmlContent, user_id: me && me.id ? me.id : 1, tag_ids: currentNoteTagIds.slice() }
const j = await post('/api/cloud_notes/note/save', body)
if (j && j.ok && j.id) {
lastSavedHtml = normalizeHtmlForDirty(htmlContent)
appendLoading(`克隆成功 新 id=${j.id}`)
try {
appendLoading('开始克隆附件')
await post('/api/cloud_notes/note/clone_attachments', { src_id: srcId, dest_id: j.id })
appendLoading('附件克隆完成')
} catch (e) {
appendLoading(`附件克隆失败: ${String(e && e.message || 'error')}`)
}
currentId = j.id
currentVersionId = Number(j.version_id || 0)
updateAttachmentVisibility()
await loadNotesList()
await selectNote(currentId)
} else {
appendLoading('克隆失败')
}
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const renderAttachments = rows => {
const box = el('cn-attachments')
if (!box) return
box.innerHTML = ''
const list = Array.isArray(rows) ? rows : []
if (!list.length) return
const pickIcon = filename => {
const name = String(filename || '')
const idx = name.lastIndexOf('.')
const ext = idx >= 0 ? name.slice(idx + 1).toLowerCase() : ''
if (!ext) return '📄'
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) return '🖼️'
if (['pdf'].includes(ext)) return '📕'
if (['doc','docx'].includes(ext)) return '📘'
if (['xls','xlsx','csv'].includes(ext)) return '📊'
if (['ppt','pptx'].includes(ext)) return '📑'
if (['zip','rar','7z','gz','tar'].includes(ext)) return '🗜️'
if (['txt','md','log'].includes(ext)) return '📄'
return '📁'
}
const fmtSize = n => {
const v = Number(n || 0)
if (!Number.isFinite(v) || v <= 0) return ''
if (v < 1024) return `${v} B`
const kb = v / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
return `${mb.toFixed(1)} MB`
}
list.forEach(r => {
const card = document.createElement('div')
card.className = 'cn-attach-card'
const top = document.createElement('div')
top.className = 'row-top'
const main = document.createElement('div')
main.className = 'row-main'
const ico = document.createElement('span')
ico.className = 'file-ico'
ico.textContent = pickIcon(r.filename)
const name = document.createElement('a')
name.href = r.file_path
name.target = '_blank'
name.className = 'name'
name.textContent = r.filename || ''
name.title = r.filename || ''
main.appendChild(ico)
main.appendChild(name)
top.appendChild(main)
const quick = document.createElement('div')
quick.className = 'row-quick'
const previewBtn = document.createElement('button')
previewBtn.type = 'button'
previewBtn.className = 'icon-btn preview'
previewBtn.title = '预览'
previewBtn.textContent = '👁'
previewBtn.onclick = () => { if (r.file_path) window.open(r.file_path, '_blank') }
const downloadBtn = document.createElement('a')
downloadBtn.href = r.file_path
downloadBtn.target = '_blank'
downloadBtn.className = 'icon-btn download'
downloadBtn.title = '下载'
downloadBtn.textContent = '⬇'
downloadBtn.setAttribute('download', r.filename || '')
quick.appendChild(previewBtn)
quick.appendChild(downloadBtn)
top.appendChild(quick)
const meta = document.createElement('div')
meta.className = 'row-meta'
const sizeSpan = document.createElement('span')
sizeSpan.className = 'size'
sizeSpan.textContent = fmtSize(r.file_size)
const dot = document.createElement('span')
dot.className = 'dot'
dot.textContent = '•'
const timeSpan = document.createElement('span')
timeSpan.className = 'time'
timeSpan.textContent = r.created_at || ''
meta.appendChild(sizeSpan)
meta.appendChild(dot)
meta.appendChild(timeSpan)
const acts = document.createElement('div')
acts.className = 'row-actions'
const delBtn = document.createElement('button')
delBtn.type = 'button'
delBtn.className = 'cn-attach-del'
delBtn.textContent = '删除'
delBtn.onclick = async () => {
const ok = await confirmModal('删除附件', '确定要删除这个附件吗?')
if (!ok) return
try {
showLoading('删除附件', `目标 id=${r.id}`)
await fetch(`/api/cloud_notes/attachments/${encodeURIComponent(r.id)}`, { method: 'DELETE' })
await loadAttachments(currentId)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
acts.appendChild(delBtn)
card.appendChild(top)
card.appendChild(meta)
card.appendChild(acts)
box.appendChild(card)
})
}
const loadAttachments = async (noteId) => {
const nid = noteId || currentId
const box = el('cn-attachments')
if (!nid) {
if (box) box.innerHTML = ''
return
}
try {
const j = await get(`/api/cloud_notes/attachments?note_id=${encodeURIComponent(nid)}`)
const rows = Array.isArray(j.rows) ? j.rows : []
renderAttachments(rows)
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`) }
}
const attachFile = async file => {
try {
showLoading('上传附件', '开始处理文件')
if (!currentId) { appendLoading('请先保存一次笔记,再上传附件'); hideLoading(); return }
appendLoading(`文件名: ${file.name}, 大小: ${file.size} 字节`)
const r = await fetch(`/api/cloud_notes/attachment?note_id=${encodeURIComponent(String(currentId || 0))}&filename=${encodeURIComponent(file.name)}`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file })
if (!r.ok) throw new Error('upload_error')
const j = await r.json()
if (!j.ok || !j.path) throw new Error('bad_resp')
appendLoading(`上传成功: ${j.path}`)
await loadAttachments(currentId)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const htmlToText = html => {
const div = document.createElement('div')
div.innerHTML = String(html || '')
const text = div.textContent || ''
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
const buildDiffOps = (leftText, rightText) => {
const leftLines = String(leftText || '').split('\n')
const rightLines = String(rightText || '').split('\n')
const n = leftLines.length
const m = rightLines.length
const maxCells = 160000
if (n * m > maxCells) {
const ops = []
const maxLen = Math.max(n, m)
for (let i = 0; i < maxLen; i++) {
const l = leftLines[i]
const r = rightLines[i]
if (l === r) {
if (typeof l !== 'undefined') ops.push({ type: 'same', left: l, right: r })
} else {
if (typeof l !== 'undefined') ops.push({ type: 'del', left: l, right: '' })
if (typeof r !== 'undefined') ops.push({ type: 'add', left: '', right: r })
}
}
return ops
}
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (leftLines[i - 1] === rightLines[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
const ops = []
let i = n
let j = m
while (i > 0 && j > 0) {
if (leftLines[i - 1] === rightLines[j - 1]) {
ops.push({ type: 'same', left: leftLines[i - 1], right: rightLines[j - 1] })
i--
j--
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
ops.push({ type: 'del', left: leftLines[i - 1], right: '' })
i--
} else {
ops.push({ type: 'add', left: '', right: rightLines[j - 1] })
j--
}
}
while (i > 0) {
ops.push({ type: 'del', left: leftLines[i - 1], right: '' })
i--
}
while (j > 0) {
ops.push({ type: 'add', left: '', right: rightLines[j - 1] })
j--
}
ops.reverse()
return ops
}
const renderCompareFromHtml = (leftHtml, rightHtml, leftLabel, rightLabel) => {
const leftPane = el('cn-compare-left')
const rightPane = el('cn-compare-right')
if (leftPane) leftPane.innerHTML = ''
if (rightPane) rightPane.innerHTML = ''
const leftTitle = el('cn-compare-left-title')
const rightTitle = el('cn-compare-right-title')
if (leftTitle) leftTitle.textContent = leftLabel || '左侧版本'
if (rightTitle) rightTitle.textContent = rightLabel || '右侧版本'
const ops = buildDiffOps(htmlToText(leftHtml), htmlToText(rightHtml))
ops.forEach(op => {
const leftLine = document.createElement('div')
const rightLine = document.createElement('div')
leftLine.className = 'cn-diff-line'
rightLine.className = 'cn-diff-line'
if (op.type === 'add') {
leftLine.classList.add('empty')
rightLine.classList.add('add')
leftLine.textContent = ''
rightLine.textContent = op.right || ''
} else if (op.type === 'del') {
leftLine.classList.add('del')
rightLine.classList.add('empty')
leftLine.textContent = op.left || ''
rightLine.textContent = ''
} else {
leftLine.textContent = op.left || ''
rightLine.textContent = op.right || ''
}
if (leftPane) leftPane.appendChild(leftLine)
if (rightPane) rightPane.appendChild(rightLine)
})
}
const openVersionsModal = () => {
const m = el('cn-versions-modal')
if (m) m.style.display = ''
}
const closeVersionsModal = () => {
const m = el('cn-versions-modal')
if (m) m.style.display = 'none'
}
const versionLabel = v => {
if (!v) return ''
const t = String(v.saved_at || '')
const name = String(v.title || '未命名')
return `${t} · ${name}`
}
const renderCompareByIds = () => {
const left = versionsCache.find(v => String(v.id) === String(compareLeftId))
const right = versionsCache.find(v => String(v.id) === String(compareRightId))
if (!left || !right) return
renderCompareFromHtml(left.html, right.html, versionLabel(left), versionLabel(right))
}
const renderVersionsList = () => {
const listBox = el('cn-versions-list')
if (!listBox) return
listBox.innerHTML = ''
versionsCache.forEach(v => {
const row = document.createElement('div')
row.className = 'cn-version-row'
const main = document.createElement('div')
main.className = 'cn-version-main'
const title = document.createElement('div')
title.className = 'cn-version-title'
title.textContent = v.title || '未命名'
const time = document.createElement('div')
time.className = 'cn-version-time'
time.textContent = v.saved_at || ''
main.appendChild(title)
main.appendChild(time)
const acts = document.createElement('div')
acts.className = 'cn-version-actions'
const leftBtn = document.createElement('button')
leftBtn.type = 'button'
leftBtn.className = 'mini-btn'
leftBtn.textContent = String(compareLeftId) === String(v.id) ? '左侧✓' : '设为左侧'
leftBtn.onclick = () => { compareLeftId = v.id; renderVersionsList() }
const rightBtn = document.createElement('button')
rightBtn.type = 'button'
rightBtn.className = 'mini-btn'
rightBtn.textContent = String(compareRightId) === String(v.id) ? '右侧✓' : '设为右侧'
rightBtn.onclick = () => { compareRightId = v.id; renderVersionsList() }
const restoreBtn = document.createElement('button')
restoreBtn.type = 'button'
restoreBtn.className = 'mini-btn'
restoreBtn.textContent = '恢复到编辑器'
restoreBtn.onclick = () => {
if (tinymce.activeEditor) tinymce.activeEditor.setContent(v.html || '')
txt('cn-title', v.title || '')
currentId = v.note_id || currentId
currentVersionId = v.id || currentVersionId
lastSavedHtml = ''
status('cn-login-status', '已恢复到编辑器,待保存')
setRightView('editor')
markActive(currentId)
}
acts.appendChild(leftBtn)
acts.appendChild(rightBtn)
acts.appendChild(restoreBtn)
row.appendChild(main)
row.appendChild(acts)
listBox.appendChild(row)
})
const summary = el('cn-versions-summary')
if (summary) summary.textContent = `共 ${versionsCache.length} 条版本`
}
const openConflictModal = () => {
const m = el('cn-conflict-modal')
if (!m) return
const desc = el('cn-conflict-desc')
const time = conflictLatest && conflictLatest.saved_at ? String(conflictLatest.saved_at) : ''
const title = conflictLatest && conflictLatest.title ? String(conflictLatest.title) : ''
if (desc) desc.textContent = `检测到服务器已有新版本${time ? `(${time})` : ''}${title ? `:${title}` : ''},当前内容尚未保存。可先对比再决定是否覆盖保存。`
m.style.display = ''
}
const closeConflictModal = () => {
const m = el('cn-conflict-modal')
if (m) m.style.display = 'none'
}
const saveNoteForce = async () => {
try {
const title = el('cn-title').value.trim() || '未命名'
if (!(await confirmCloneSave(title))) return
showLoading('覆盖保存', '正在提交内容')
const htmlContent = tinymce.activeEditor ? tinymce.activeEditor.getContent() : ''
const body = { id: currentId || 0, title, html: htmlContent, user_id: me && me.id ? me.id : 1, tag_ids: currentNoteTagIds.slice(), base_version_id: currentVersionId || 0, force: true }
const r = await fetch('/api/cloud_notes/note/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store' })
let j = null
try { j = await r.json() } catch {}
if (r.status === 409 || (j && j.conflict)) {
conflictLatest = j && j.latest ? j.latest : conflictLatest
hideLoading()
openConflictModal()
return
}
if (j && j.ok) {
currentId = j.id
if (j.version_id) currentVersionId = Number(j.version_id || 0)
lastSavedHtml = normalizeHtmlForDirty(htmlContent)
updateAttachmentVisibility()
appendLoading(`保存成功 id=${currentId}`)
status('cn-login-status', '已保存')
await loadNotesList()
markActive(currentId)
await loadAttachments(currentId)
} else {
appendLoading('保存失败')
}
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const listVersions = async () => {
if (!currentId) {
await confirmModal('提示', '请先选择一个笔记再查看版本')
return
}
try {
showLoading('获取版本', '请求版本列表')
const j = await get(`/api/cloud_notes/note/versions?id=${encodeURIComponent(currentId || 0)}`)
versionsCache = Array.isArray(j.versions) ? j.versions : []
if (versionsCache.length) {
compareLeftId = versionsCache[0].id
compareRightId = versionsCache.length > 1 ? versionsCache[1].id : versionsCache[0].id
} else {
compareLeftId = 0
compareRightId = 0
}
renderVersionsList()
if (compareLeftId && compareRightId) renderCompareByIds()
openVersionsModal()
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const renderList = rows => {
const ul = el('cn-list')
if (!ul) return
ul.innerHTML = ''
rows.forEach(r => {
const li = document.createElement('li')
li.dataset.id = String(r.id)
const name = document.createElement('div')
name.className = 'name'
name.textContent = r.title || ''
const flags = document.createElement('div')
flags.className = 'flags'
if (r.pinned) {
const pin = document.createElement('span')
pin.className = 'flag'
pin.textContent = '📌'
flags.appendChild(pin)
}
if (r.starred) {
const star = document.createElement('span')
star.className = 'flag'
star.textContent = '⭐'
flags.appendChild(star)
}
li.appendChild(name)
li.appendChild(flags)
li.onclick = () => selectNote(r.id)
ul.appendChild(li)
})
markActive(currentId)
}
const renderTrashList = rows => {
const ul = el('cn-list')
if (!ul) return
ul.innerHTML = ''
rows.forEach(r => {
const li = document.createElement('li')
li.dataset.id = String(r.id)
const name = document.createElement('div')
name.className = 'name'
name.textContent = r.title
const flags = document.createElement('div')
flags.className = 'flags'
const restoreBtn = document.createElement('button')
restoreBtn.type = 'button'
restoreBtn.className = 'mini-btn'
restoreBtn.textContent = '还原'
restoreBtn.onclick = e => { e.stopPropagation(); restoreFromTrash(r.id) }
const delBtn = document.createElement('button')
delBtn.type = 'button'
delBtn.className = 'mini-btn'
delBtn.textContent = '彻底删除'
delBtn.onclick = e => { e.stopPropagation(); hardDeleteFromTrash(r.id) }
flags.appendChild(restoreBtn)
flags.appendChild(delBtn)
li.appendChild(name)
li.appendChild(flags)
ul.appendChild(li)
})
}
const markActive = id => {
const ul = el('cn-list'); if (!ul) return
let active = null
ul.querySelectorAll('li').forEach(li => {
const on = String(li.dataset.id||'') === String(id||'')
li.classList.toggle('active', on)
if (on) active = li
})
if (active && typeof active.scrollIntoView === 'function') {
active.scrollIntoView({ block: 'nearest' })
}
}
const loadNotesList = async () => {
try {
showLoading('加载列表', '请求笔记列表')
const j = await get('/api/cloud_notes/note/list')
const rows = Array.isArray(j.rows) ? j.rows : []
renderList(rows)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const loadTrashList = async () => {
try {
showLoading('加载回收站', '请求回收站列表')
const j = await get('/api/cloud_notes/note/trash_list')
const rows = Array.isArray(j.rows) ? j.rows : []
renderTrashList(rows)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const loadNotesByTag = async () => {
if (!activeTagId) return loadNotesList()
try {
showLoading('加载标签笔记', `请求标签 id=${activeTagId}`)
const j = await get(`/api/cloud_notes/note/list_by_tag?tag_id=${encodeURIComponent(activeTagId)}`)
const rows = Array.isArray(j.rows) ? j.rows : []
renderList(rows)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const loadList = async () => {
if (currentTab === 'trash') return loadTrashList()
if (currentTab === 'notes') return loadNotesList()
if (currentTab === 'tags') return loadNotesByTag()
const ul = el('cn-list')
if (ul) ul.innerHTML = ''
}
const applyMainTabState = () => {
const tabs = Array.from(document.querySelectorAll('.cn-main-tab'))
const leftTitle = el('cn-left-title')
tabs.forEach(t => {
const key = t.dataset.tab || ''
t.classList.toggle('active', key === currentTab)
})
if (leftTitle) {
if (currentTab === 'trash') leftTitle.textContent = '回收站'
else if (currentTab === 'tags') leftTitle.textContent = '标签'
else leftTitle.textContent = ''
}
const newBtn = el('cn-new')
const starBtn = el('cn-star')
const pinBtn = el('cn-pin')
const deleteBtn = el('cn-delete')
if (newBtn) newBtn.style.display = currentTab === 'notes' ? '' : 'none'
if (starBtn) starBtn.style.display = currentTab === 'notes' ? '' : 'none'
if (pinBtn) pinBtn.style.display = currentTab === 'notes' ? '' : 'none'
if (deleteBtn) deleteBtn.style.display = currentTab === 'notes' ? '' : 'none'
renderTagFilterBar()
}
const setMainTab = name => {
if (name !== 'notes' && name !== 'tags' && name !== 'trash') name = 'notes'
currentTab = name
applyMainTabState()
loadList()
}
const selectNote = async id => {
try {
if (!(await ensureCanLeaveNote())) return
showLoading('加载笔记', `请求 id=${id}`)
const j = await get(`/api/cloud_notes/note/get?id=${encodeURIComponent(id)}`)
if (j && j.ok && j.row) {
currentId = j.row.id
currentVersionId = Number(j.row.version_id || 0)
updateAttachmentVisibility()
txt('cn-title', j.row.title || '')
if (tinymce.activeEditor) tinymce.activeEditor.setContent(String(j.row.html || ''))
lastSavedHtml = normalizeHtmlForDirty(String(j.row.html || ''))
const tids = Array.isArray(j.row.tag_ids) ? j.row.tag_ids : []
setCurrentNoteTags(tids)
markActive(currentId)
await loadAttachments(currentId)
setRightView('editor') // 切换到编辑器视图
}
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const toggleStar = async () => {
if (!currentId) return
try {
showLoading('加星设置', `目标 id=${currentId}`)
const wasStar = (() => {
const li = document.querySelector(`.cn-list li[data-id="${currentId}"]`)
return !!(li && li.querySelector('.flag') && li.querySelector('.flag').textContent.includes('⭐'))
})()
await post('/api/cloud_notes/note/star', { id: currentId, starred: !wasStar })
await loadNotesList()
markActive(currentId)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const togglePin = async () => {
if (!currentId) return
try {
showLoading('置顶设置', `目标 id=${currentId}`)
const li = document.querySelector(`.cn-list li[data-id="${currentId}"]`)
const wasPin = !!(li && Array.from(li.querySelectorAll('.flag')).some(n => n.textContent.includes('📌')))
await post('/api/cloud_notes/note/pin', { id: currentId, pinned: !wasPin })
await loadNotesList()
markActive(currentId)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const softDeleteCurrent = async () => {
if (!ensureUnlocked()) return
if (!currentId) {
await confirmModal('提示', '请先选择要删除的笔记')
return
}
const ok = await confirmModal('删除笔记', '删除后将进入回收站,确定要删除吗?')
if (!ok) return
try {
const id = currentId
showLoading('删除笔记', `目标 id=${currentId}`)
await post('/api/cloud_notes/note/trash', { id })
if (String(currentId || '') === String(id || '')) {
currentId = 0
txt('cn-title', '')
if (tinymce.activeEditor) tinymce.activeEditor.setContent('')
html('cn-attachments', '')
updateAttachmentVisibility()
}
const li = document.querySelector(`.cn-list li[data-id="${id}"]`)
if (li && li.parentNode) li.parentNode.removeChild(li)
await loadNotesList()
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const restoreFromTrash = async id => {
if (!ensureUnlocked()) return
try {
showLoading('还原笔记', `目标 id=${id}`)
await post('/api/cloud_notes/note/restore', { id })
currentId = id
updateAttachmentVisibility()
setMainTab('notes')
await selectNote(id)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const hardDeleteFromTrash = async id => {
if (!ensureUnlocked()) return
const ok = await confirmModal('彻底删除', '彻底删除后将无法恢复,确定要继续吗?')
if (!ok) return
try {
showLoading('彻底删除笔记', `目标 id=${id}`)
await post('/api/cloud_notes/note/trash_delete', { id })
await loadTrashList()
if (String(currentId || '') === String(id || '')) {
currentId = 0
txt('cn-title', '')
if (tinymce.activeEditor) tinymce.activeEditor.setContent('')
html('cn-attachments', '')
updateAttachmentVisibility()
}
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const stripHtml = s => String(s || '').replace(/<[^>]+>/g, ' ')
const fmtDate = s => {
if (!s) return ''
return String(s).replace('T', ' ').slice(0, 19)
}
const fmtSize = n => {
const v = Number(n || 0)
if (!Number.isFinite(v) || v <= 0) return ''
if (v < 1024) return `${v} B`
const kb = v / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
if (mb < 1024) return `${mb.toFixed(1)} MB`
const gb = mb / 1024
return `${gb.toFixed(1)} GB`
}
const renderDashboard = data => {
const box = el('cn-dashboard')
if (!box) return
const d = data || {}
const ov = d.overview || {}
const total = Number(ov.total_notes || 0)
const today = Number(ov.today_count || 0)
const week = Number(ov.week_count || 0)
const starred = Number(ov.starred_count || 0)
const pinned = Number(ov.pinned_count || 0)
const recent = Array.isArray(d.recent_notes) ? d.recent_notes : []
const pinnedNotes = Array.isArray(d.pinned_notes) ? d.pinned_notes : []
const starredNotes = Array.isArray(d.starred_notes) ? d.starred_notes : []
const tags = Array.isArray(d.hot_tags) ? d.hot_tags : []
const attachments = Array.isArray(d.recent_attachments) ? d.recent_attachments : []
const untagged = Number(d.untagged_count || 0)
const lastId = Number(d.last_note_id || 0)
const buildNotesList = (list, emptyText) => {
const arr = Array.isArray(list) ? list : []
if (!arr.length) return `<div class="cn-dashboard-empty">${emptyText}</div>`
const items = arr.map(n => {
const id = n.id
const title = escapeHtml(n.title || '')
const time = fmtDate(n.updated_at || n.created_at || '')
return `<li data-id="${id}"><span class="name">${title}</span><span class="meta">${time}</span></li>`
})
return `<ul class="cn-dash-list">${items.join('')}</ul>`
}
const buildTags = () => {
if (!tags.length && !untagged) return `<div class="cn-dashboard-empty">暂无标签数据</div>`
const rows = []
tags.forEach(t => {
const count = typeof t.note_count === 'number' ? t.note_count : 0
const color = String(t.color || '').trim()
const style = color ? ` style="background:${escapeHtml(color)};"` : ''
rows.push(`<div class="cn-dash-tag-row" data-tag-id="${t.id}"><span class="dot"${style}></span><span class="name">${escapeHtml(t.name || '')}</span><span class="count">${count}</span></div>`)
})
if (untagged) rows.push(`<div class="cn-dash-tag-row untagged"><span class="dot"></span><span class="name">未打标签</span><span class="count">${untagged}</span></div>`)
return rows.join('')
}
const buildAttachments = () => {
if (!attachments.length) return `<div class="cn-dashboard-empty">暂无附件</div>`
const pickSize = n => {
const v = Number(n || 0)
if (!Number.isFinite(v) || v <= 0) return ''
if (v < 1024) return `${v} B`
const kb = v / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
return `${mb.toFixed(1)} MB`
}
const items = attachments.slice(0, 6).map(a => {
const path = escapeHtml(a.file_path || '')
const filename = escapeHtml(a.filename || '')
const sizeText = pickSize(a.file_size)
const parts = []
if (sizeText) parts.push(sizeText)
if (a.created_at) parts.push(fmtDate(a.created_at))
const meta = parts.join(' · ')
const title = a.note_title ? `所属笔记:${escapeHtml(a.note_title)}` : ''
return `<li data-path="${path}" title="${title}"><span class="name">📎 ${filename}</span><span class="meta">${meta}</span></li>`
})
return `<ul class="cn-dash-list cn-dash-attachments">${items.join('')}</ul>`
}
const mergedImportant = (() => {
const pinnedIds = new Set()
const list = []
pinnedNotes.forEach(n => {
pinnedIds.add(String(n.id || ''))
list.push(n)
})
starredNotes.forEach(n => {
const id = String(n.id || '')
if (!pinnedIds.has(id)) list.push(n)
})
return list
})()
box.innerHTML = [
'<div class="cn-dashboard-grid">',
// 统计卡片
'<div class="cn-dash-grid-card">',
'<div class="cn-dash-grid-header">',
'<div class="cn-dash-grid-icon">📊</div>',
'<div class="cn-dash-grid-title">数据概览</div>',
'</div>',
'<div class="cn-dash-grid-content">',
'<div class="cn-dash-grid-stats">',
'<div class="cn-dash-grid-stat">',
'<div class="label">总笔记</div>',
`<div class="value">${total}</div>`,
'</div>',
'<div class="cn-dash-grid-stat">',
'<div class="label">今日更新</div>',
`<div class="value">${today}</div>`,
'</div>',
'<div class="cn-dash-grid-stat">',
'<div class="label">近7天更新</div>',
`<div class="value">${week}</div>`,
'</div>',
'<div class="cn-dash-grid-stat">',
'<div class="label">加星 / 置顶</div>',
`<div class="value">${starred} / ${pinned}</div>`,
'</div>',
'</div>',
'</div>',
'</div>',
// 快捷操作卡片
'<div class="cn-dash-grid-card">',
'<div class="cn-dash-grid-header">',
'<div class="cn-dash-grid-icon">⚡</div>',
'<div class="cn-dash-grid-title">快捷操作</div>',
'</div>',
'<div class="cn-dash-grid-content">',
'<div class="cn-dash-grid-actions">',
'<button type="button" class="cn-dash-grid-btn" data-action="new-note">新建笔记</button>',
lastId ? '<button type="button" class="cn-dash-grid-btn" data-action="open-last">最近编辑</button>' : '',
'<button type="button" class="cn-dash-grid-btn" data-action="open-search">全文搜索</button>',
'</div>',
`<div class="cn-dash-grid-sub">${untagged} 条未打标签</div>`,
'</div>',
'</div>',
// 最近笔记卡片
'<div class="cn-dash-grid-card">',
'<div class="cn-dash-grid-header">',
'<div class="cn-dash-grid-icon">🕒</div>',
'<div class="cn-dash-grid-title">最近笔记</div>',
'</div>',
'<div class="cn-dash-grid-content">',
buildNotesList(recent.slice(0, 8), '暂无最近笔记'),
'</div>',
'</div>',
// 重点笔记卡片
'<div class="cn-dash-grid-card">',
'<div class="cn-dash-grid-header">',
'<div class="cn-dash-grid-icon">⭐</div>',
'<div class="cn-dash-grid-title">重点笔记</div>',
'</div>',
'<div class="cn-dash-grid-content">',
buildNotesList(mergedImportant.slice(0, 4), '暂无重点笔记'),
'</div>',
'</div>',
// 标签概览卡片
'<div class="cn-dash-grid-card">',
'<div class="cn-dash-grid-header">',
'<div class="cn-dash-grid-icon">🏷️</div>',
'<div class="cn-dash-grid-title">标签管理</div>',
'</div>',
'<div class="cn-dash-grid-content">',
buildTags(),
'</div>',
'</div>',
// 附件卡片
'<div class="cn-dash-grid-card">',
'<div class="cn-dash-grid-header">',
'<div class="cn-dash-grid-icon">📎</div>',
'<div class="cn-dash-grid-title">附件中心</div>',
'</div>',
'<div class="cn-dash-grid-content">',
buildAttachments(),
'</div>',
'</div>',
'</div>'
].join('')
Array.from(box.querySelectorAll('.cn-dash-list li[data-id]')).forEach(li => {
li.onclick = () => {
const id = li.dataset.id
if (!id) return
selectNote(id)
setRightView('editor')
}
})
Array.from(box.querySelectorAll('.cn-dash-tag-row[data-tag-id]')).forEach(row => {
row.onclick = () => {
const tid = parseInt(String(row.dataset.tagId || '0'), 10) || 0
if (!tid) return
activeTagId = tid
currentTab = 'tags'
renderTagFilterBar()
loadNotesByTag()
}
})
Array.from(box.querySelectorAll('.cn-dash-attachments li')).forEach(li => {
li.onclick = () => {
const path = li.dataset.path
if (path) window.open(path, '_blank')
}
})
const quickNew = box.querySelector('[data-action="new-note"]')
if (quickNew) quickNew.onclick = async () => {
if (!ensureUnlocked()) return
if (!(await ensureCanLeaveNote())) return
setRightView('editor')
newNote()
}
const quickLast = box.querySelector('[data-action="open-last"]')
if (quickLast && lastId) quickLast.onclick = () => {
if (!ensureUnlocked()) return
selectNote(lastId)
setRightView('editor')
}
const quickSearch = box.querySelector('[data-action="open-search"]')
if (quickSearch) quickSearch.onclick = () => {
if (!ensureUnlocked()) return
openSearch()
}
}
const loadDashboard = async () => {
try {
showLoading('加载驾驶舱', '请求概览数据')
const j = await get('/api/cloud_notes/dashboard')
if (j && j.ok) {
dashboardData = j.data || null
renderDashboard(dashboardData)
}
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const setRightView = name => {
rightView = name === 'editor' ? 'editor' : 'dashboard'
const dash = el('cn-dashboard')
const pane = el('cn-editor-pane')
if (dash) dash.style.display = rightView === 'dashboard' ? '' : 'none'
if (pane) pane.style.display = rightView === 'editor' ? '' : 'none'
const title = el('cn-right-title')
if (title) title.textContent = rightView === 'dashboard' ? '驾驶舱' : '笔记内容'
const tabDash = el('cn-right-tab-dashboard')
const tabEditor = el('cn-right-tab-editor')
if (tabDash) tabDash.classList.toggle('active', rightView === 'dashboard')
if (tabEditor) tabEditor.classList.toggle('active', rightView === 'editor')
const saveBtn = el('cn-save')
if (saveBtn) saveBtn.style.display = rightView === 'editor' ? '' : 'none'
}
let searchRows = []
let searchSort = 'default'
const applySearchSort = () => {
if (!Array.isArray(searchRows)) return []
const rows = searchRows.slice()
if (searchSort === 'title') rows.sort((a,b) => String(a.title||'').localeCompare(String(b.title||'')))
else if (searchSort === 'updated_at') rows.sort((a,b) => String(b.updated_at||'').localeCompare(String(a.updated_at||'')))
else if (searchSort === 'created_at') rows.sort((a,b) => String(b.created_at||'').localeCompare(String(a.created_at||'')))
else if (searchSort === 'size') rows.sort((a,b) => Number(b.size_bytes||0) - Number(a.size_bytes||0))
return rows
}
const highlightKeyword = (text, kw) => {
const raw = String(text || '')
const q = String(kw || '').trim()
if (!q) return escapeHtml(raw)
const re = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig')
let out = ''
let last = 0
raw.replace(re, (...args) => {
const offset = args[args.length - 2]
out += escapeHtml(raw.slice(last, offset))
out += `<mark>${escapeHtml(args[0])}</mark>`
last = offset + args[0].length
return args[0]
})
out += escapeHtml(raw.slice(last))
return out
}
const renderSearchResults = (rows, keyword) => {
const box = document.getElementById('cn-search-results')
const info = document.getElementById('cn-search-count')
if (!box || !info) return
searchRows = Array.isArray(rows) ? rows : []
const sorted = applySearchSort()
info.textContent = sorted.length ? `共 ${sorted.length} 项` : ''
if (!sorted.length) {
box.innerHTML = ''
return
}
const kw = String(keyword || '').trim()
const pickSnippet = r => {
const plain = stripHtml(r.html || '')
if (!kw) return plain.slice(0, 80)
const idx = plain.toLowerCase().indexOf(kw.toLowerCase())
if (idx < 0) return plain.slice(0, 80)
const start = Math.max(0, idx - 16)
const end = Math.min(plain.length, idx + kw.length + 40)
return (start > 0 ? '…' : '') + plain.slice(start, end) + (end < plain.length ? '…' : '')
}
box.innerHTML = ''
sorted.forEach(r => {
const title = highlightKeyword(r.title || '', kw)
const snippet = highlightKeyword(pickSnippet(r), kw)
const meta = []
if (r.updated_at) meta.push(`修改: ${fmtDate(r.updated_at)}`)
if (r.created_at) meta.push(`创建: ${fmtDate(r.created_at)}`)
if (r.size_bytes) meta.push(`大小: ${fmtSize(r.size_bytes)}`)
const item = document.createElement('div')
item.className = 'cn-search-item'
item.dataset.id = String(r.id || '')
const titleNode = document.createElement('div')
titleNode.className = 'cn-search-item-title'
titleNode.innerHTML = title
const snippetNode = document.createElement('div')
snippetNode.className = 'cn-search-item-snippet'
snippetNode.innerHTML = snippet
const metaNode = document.createElement('div')
metaNode.className = 'cn-search-item-meta'
metaNode.textContent = meta.join(' | ')
item.appendChild(titleNode)
item.appendChild(snippetNode)
item.appendChild(metaNode)
box.appendChild(item)
})
Array.from(box.querySelectorAll('.cn-search-item')).forEach(node => {
node.onclick = () => {
const id = node.dataset.id
if (!id) return
closeSearch()
selectNote(id)
}
})
}
const openSearch = () => {
if (!ensureUnlocked()) return
const layer = document.getElementById('cn-search-layer')
if (!layer) return
layer.style.display = ''
const input = document.getElementById('cn-search-input')
if (input) {
input.focus()
input.select()
}
}
const closeSearch = () => {
const layer = document.getElementById('cn-search-layer')
if (!layer) return
layer.style.display = 'none'
}
const doSearch = async () => {
if (!ensureUnlocked()) return
const input = document.getElementById('cn-search-input')
if (!input) return
const q = String(input.value || '').trim()
if (!q) {
renderSearchResults([], '')
return
}
try {
showLoading('搜索笔记', `关键字: ${q}`)
const j = await get(`/api/cloud_notes/note/search?q=${encodeURIComponent(q)}`)
if (j && j.ok) renderSearchResults(Array.isArray(j.rows) ? j.rows : [], j.keyword || q)
hideLoading()
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const bindSearchUI = () => {
const btn = el('cn-search')
if (btn) btn.onclick = () => openSearch()
const closeBtn = document.getElementById('cn-search-close')
if (closeBtn) closeBtn.onclick = () => closeSearch()
const input = document.getElementById('cn-search-input')
if (input) input.onkeydown = e => {
if (e.key === 'Enter') doSearch()
}
const sortBtn = document.getElementById('cn-search-sort-btn')
const menu = document.getElementById('cn-search-sort-menu')
if (sortBtn && menu) {
sortBtn.onclick = () => {
menu.style.display = menu.style.display === 'none' || !menu.style.display ? '' : 'none'
}
Array.from(menu.querySelectorAll('button')).forEach(b => {
b.onclick = () => {
const v = b.dataset.sort || 'default'
searchSort = v
Array.from(menu.querySelectorAll('button')).forEach(x => x.classList.toggle('active', x === b))
if (v === 'title') sortBtn.textContent = '笔记名称'
else if (v === 'updated_at') sortBtn.textContent = '修改时间'
else if (v === 'created_at') sortBtn.textContent = '创建时间'
else if (v === 'size') sortBtn.textContent = '笔记大小'
else sortBtn.textContent = '默认排序'
renderSearchResults(searchRows, document.getElementById('cn-search-input') ? document.getElementById('cn-search-input').value : '')
menu.style.display = 'none'
}
})
}
}
const syncSettingsUI = () => {
const u = el('cn-settings-username')
if (u && me && me.username) u.value = me.username
const idleInput = el('cn-settings-idle-minutes')
if (idleInput) idleInput.value = idleMinutes ? String(idleMinutes) : ''
}
const openSettings = () => {
if (!ensureUnlocked()) return
const m = el('cn-settings-modal')
if (!m) return
syncSettingsUI()
m.style.display = ''
}
const closeSettings = () => {
const m = el('cn-settings-modal')
if (!m) return
m.style.display = 'none'
}
const bindSettingsUI = () => {
const modal = el('cn-settings-modal')
if (!modal) return
const tabs = Array.from(modal.querySelectorAll('.cn-settings-tab'))
const panels = Array.from(modal.querySelectorAll('.cn-settings-panel'))
const setActive = name => {
tabs.forEach(t => {
const key = t.dataset.tab || ''
t.classList.toggle('active', key === name)
})
panels.forEach(p => {
const key = p.dataset.tab || ''
p.classList.toggle('active', key === name)
})
if (name === 'tags') reloadTags()
if (name === 'tiny_toolbar') loadTinyToolbarSettings()
}
tabs.forEach(t => {
t.onclick = () => {
const name = t.dataset.tab || ''
if (name) setActive(name)
}
})
const closeBtn = el('cn-settings-close')
if (closeBtn) closeBtn.onclick = () => closeSettings()
const idleSave = el('cn-settings-idle-save')
if (idleSave) idleSave.onclick = () => {
const input = el('cn-settings-idle-minutes')
const raw = input && input.value ? parseInt(input.value, 10) : 0
const m = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 720) : 0
applyIdleMinutes(m)
window.alert(m ? `已设置为 ${m} 分钟无操作锁定` : '已关闭无操作锁定')
closeSettings()
}
const tagsAdd = el('cn-settings-tags-add')
if (tagsAdd) tagsAdd.onclick = () => {
settingsTags.push({ id: 0, name: '', color: '', sort_index: settingsTags.length })
renderSettingsTags()
}
const tagsSave = el('cn-settings-tags-save')
if (tagsSave) tagsSave.onclick = async () => {
const payload = settingsTags.map((t, idx) => ({ id: t.id || 0, name: String(t.name || '').trim(), color: String(t.color || '').trim(), sort_index: idx })).filter(t => t.name)
try {
showLoading('保存标签', '正在提交标签列表')
await post('/api/cloud_notes/tags/save', { tags: payload })
await reloadTags()
hideLoading()
window.alert('标签已保存')
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
const tinyToolbarSave = el('cn-settings-tiny-toolbar-save')
if (tinyToolbarSave) tinyToolbarSave.onclick = async () => {
const payload = normalizeTinyToolbarSettings(tinyToolbarSettings)
try {
showLoading('保存工具栏设置', '正在提交配置')
const j = await post('/api/cloud_notes/settings/tiny_toolbar', { settings: payload })
if (j && j.settings) {
tinyToolbarSettings = normalizeTinyToolbarSettings(j.settings)
}
hideLoading()
window.alert('工具栏设置已保存,将重新加载编辑器')
try {
const ed = window.tinymce && tinymce.activeEditor ? tinymce.activeEditor : null
const content = ed ? ed.getContent() : ''
if (ed) tinymce.remove(ed)
await ensureTinyMCE()
initEditor()
if (content) {
setTimeout(() => {
if (tinymce.activeEditor) tinymce.activeEditor.setContent(content)
}, 0)
}
} catch {}
} catch (e) { appendLoading(`错误: ${String(e && e.message || 'error')}`); hideLoading() }
}
}
const onFullscreenResize = () => {
if (!isFullscreen) return
const editorContainer = document.querySelector('.tox-tinymce')
if (!editorContainer) return
const editorWrapper = document.querySelector('.cn-editor-wrapper')
const rightInner = document.querySelector('.cn-right-inner')
if (!editorWrapper || !rightInner) {
const h = Math.max(400, window.innerHeight - 220)
editorContainer.style.height = `${h}px`
return
}
const innerRect = rightInner.getBoundingClientRect()
const wrapperRect = editorWrapper.getBoundingClientRect()
const available = innerRect.bottom - wrapperRect.top
const h = Math.max(400, Math.floor(available))
editorContainer.style.height = `${h}px`
}
const bind = () => {
const themeBtn = el('cn-toggle-theme')
if (themeBtn) themeBtn.onclick = () => {
const next = currentTheme() === 'dark' ? 'light' : 'dark'
applyTheme(next)
}
const settingsBtn = el('cn-settings')
if (settingsBtn) settingsBtn.onclick = () => openSettings()
const attachToggleBtn = el('cn-attach-toggle')
if (attachToggleBtn) attachToggleBtn.onclick = () => {
if (!ensureUnlocked()) return
toggleAttachPanel()
}
const fullscreenBtn = el('cn-fullscreen')
if (fullscreenBtn) fullscreenBtn.onclick = toggleFullscreen
const logoutBtn = el('cn-logout-btn')
if (logoutBtn) logoutBtn.onclick = async () => {
if (!ensureUnlocked()) return
if (!(await ensureCanLeaveNote())) return
try {
showLoading('退出登录', '正在退出当前账号')
await logout()
} finally {
me = null
locked = true
show('cn-app', false)
show('cn-login', true)
const pwd = el('cn-password')
if (pwd) pwd.value = ''
hideLoading()
}
}
const dashTab = el('cn-right-tab-dashboard')
if (dashTab) dashTab.onclick = async () => {
if (!ensureUnlocked()) return
if (rightView === 'editor' && !(await ensureCanLeaveNote())) return
setRightView('dashboard')
if (!dashboardData) loadDashboard()
}
const editorTab = el('cn-right-tab-editor')
if (editorTab) editorTab.onclick = () => {
if (!ensureUnlocked()) return
setRightView('editor')
}
el('cn-login-btn').onclick = login
;(async function tryAutoFill() {
try {
const token = localStorage.getItem('toolbox_creds_token') || ''
const url = '/api/cloud_notes/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('cn-username').value = d.creds.username
if (d.creds.password) el('cn-password').value = d.creds.password
}
} catch {}
el('cn-username').removeAttribute('readonly')
el('cn-password').removeAttribute('readonly')
})()
const pwdInput = el('cn-password')
if (pwdInput) {
pwdInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const btn = el('cn-login-btn')
if (btn && !btn.disabled) btn.click()
}
})
}
el('cn-new').onclick = async () => {
if (!ensureUnlocked()) return
if (!(await ensureCanLeaveNote())) return
setRightView('editor')
newNote()
}
el('cn-save').onclick = saveNote
el('cn-refresh').onclick = loadList
el('cn-star').onclick = toggleStar
el('cn-pin').onclick = togglePin
const delBtn = el('cn-delete')
if (delBtn) delBtn.onclick = () => softDeleteCurrent()
const cloneBtn = el('cn-clone')
if (cloneBtn) cloneBtn.onclick = () => cloneCurrentNote()
const versionsBtn = el('cn-versions')
if (versionsBtn) versionsBtn.onclick = () => listVersions()
const versionsClose = el('cn-versions-close')
if (versionsClose) versionsClose.onclick = () => closeVersionsModal()
const versionsCompare = el('cn-versions-compare')
if (versionsCompare) versionsCompare.onclick = () => renderCompareByIds()
const conflictCancel = el('cn-conflict-cancel')
if (conflictCancel) conflictCancel.onclick = () => closeConflictModal()
const conflictCompare = el('cn-conflict-compare')
if (conflictCompare) conflictCompare.onclick = () => {
closeConflictModal()
if (!conflictLatest) return
openVersionsModal()
renderVersionsList()
const currentHtml = tinymce.activeEditor ? tinymce.activeEditor.getContent() : ''
renderCompareFromHtml(conflictLatest.html, currentHtml, `服务器最新 ${versionLabel(conflictLatest)}`, '当前编辑')
}
const conflictForce = el('cn-conflict-force')
if (conflictForce) conflictForce.onclick = () => { closeConflictModal(); saveNoteForce() }
const tagAddBtn = el('cn-note-tag-add')
if (tagAddBtn) tagAddBtn.onclick = () => {
if (!ensureUnlocked()) return
toggleTagPicker()
}
const tagChipsBox = el('cn-note-tag-chips')
if (tagChipsBox) tagChipsBox.onclick = e => {
if (e.target && e.target.classList && e.target.classList.contains('close')) return
if (!ensureUnlocked()) return
toggleTagPicker()
}
el('cn-attach').onclick = () => { const f = el('cn-attach-file'); if (f) f.click() }
el('cn-attach-file').onchange = e => { const f = e.target.files && e.target.files[0]; if (f) attachFile(f) }
const tabs = Array.from(document.querySelectorAll('.cn-main-tab'))
const leftTitle = el('cn-left-title')
const updateTabUI = () => {
tabs.forEach(t => {
const key = t.dataset.tab || ''
t.classList.toggle('active', key === currentTab)
})
if (leftTitle) {
if (currentTab === 'trash') leftTitle.textContent = '回收站'
else if (currentTab === 'tags') leftTitle.textContent = '标签'
else leftTitle.textContent = ''
}
const newBtn = el('cn-new')
const starBtn = el('cn-star')
const pinBtn = el('cn-pin')
const deleteBtn = el('cn-delete')
if (newBtn) newBtn.style.display = currentTab === 'notes' ? '' : 'none'
if (starBtn) starBtn.style.display = currentTab === 'notes' ? '' : 'none'
if (pinBtn) pinBtn.style.display = currentTab === 'notes' ? '' : 'none'
if (deleteBtn) deleteBtn.style.display = currentTab === 'notes' ? '' : 'none'
renderTagFilterBar()
}
const setTab = name => {
if (name !== 'notes' && name !== 'tags' && name !== 'trash') name = 'notes'
currentTab = name
updateTabUI()
loadList()
}
tabs.forEach(t => {
t.onclick = () => {
const key = t.dataset.tab || 'notes'
setTab(key)
}
})
updateTabUI()
window.addEventListener('keydown', e => {
const k = String(e.key || '').toLowerCase()
if ((e.ctrlKey || e.metaKey) && k === 's') {
e.preventDefault()
if (!ensureUnlocked()) return
saveNote()
}
})
window.addEventListener('beforeunload', e => {
if (hasUnsavedEditorChanges()) {
e.preventDefault()
e.returnValue = ''
}
})
}
const toggleFullscreen = () => {
isFullscreen = !isFullscreen
const fullscreenBtn = el('cn-fullscreen')
if (fullscreenBtn) {
const label = isFullscreen ? '缩屏' : '全屏'
fullscreenBtn.textContent = label
fullscreenBtn.title = label
fullscreenBtn.setAttribute('aria-label', label)
}
const body = document.body
if (isFullscreen) body.classList.add('cn-fullscreen')
else body.classList.remove('cn-fullscreen')
const editorContainer = document.querySelector('.tox-tinymce')
if (editorContainer) {
if (isFullscreen) {
editorContainer.dataset.cnPrevHeight = editorContainer.style.height || ''
onFullscreenResize()
if (!fullscreenResizeBound) {
fullscreenResizeBound = true
window.addEventListener('resize', onFullscreenResize)
}
} else {
const prev = editorContainer.dataset.cnPrevHeight || ''
editorContainer.style.height = prev
}
}
if (!isFullscreen && fullscreenResizeBound) {
fullscreenResizeBound = false
window.removeEventListener('resize', onFullscreenResize)
}
if (isFullscreen && rightView !== 'editor') setRightView('editor')
updateAttachmentVisibility()
}
// 中文日期格式化
const formatChineseDate = (date) => {
const now = new Date()
const diffTime = Math.abs(now - date)
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 0) {
return '今天'
} else if (diffDays === 1) {
return '昨天'
} else if (diffDays === 2) {
return '前天'
} else if (diffDays < 7) {
return `${diffDays}天前`
} else {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
}
// 登录界面背景随机切换
const initLoginBackground = () => {
const loginContainer = el('cn-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('cn-login-btn')
if (loginBtn) {
loginBtn.style.background = 'rgba(24, 144, 255, 0.9)'
loginBtn.style.border = 'none'
loginBtn.style.color = 'white'
}
}
const start = () => { initPreferences(); bind(); bindSettingsUI(); bindSearchUI(); bindIdleEvents(); initLoginBackground(); ensureAuth() }
return { start }
})()
document.addEventListener('DOMContentLoaded', () => cn.start())