2327 lines
91 KiB
JavaScript
2327 lines
91 KiB
JavaScript
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 '&'
|
||
if (c === '<') return '<'
|
||
if (c === '>') return '>'
|
||
if (c === '"') return '"'
|
||
return '''
|
||
})
|
||
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())
|