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 `
${emptyText}
` const items = arr.map(n => { const id = n.id const title = escapeHtml(n.title || '') const time = fmtDate(n.updated_at || n.created_at || '') return `
  • ${title}${time}
  • ` }) return `` } const buildTags = () => { if (!tags.length && !untagged) return `
    暂无标签数据
    ` 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(`
    ${escapeHtml(t.name || '')}${count}
    `) }) if (untagged) rows.push(`
    未打标签${untagged}
    `) return rows.join('') } const buildAttachments = () => { if (!attachments.length) return `
    暂无附件
    ` 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 `
  • 📎 ${filename}${meta}
  • ` }) return `` } 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 = [ '
    ', // 统计卡片 '
    ', '
    ', '
    📊
    ', '
    数据概览
    ', '
    ', '
    ', '
    ', '
    ', '
    总笔记
    ', `
    ${total}
    `, '
    ', '
    ', '
    今日更新
    ', `
    ${today}
    `, '
    ', '
    ', '
    近7天更新
    ', `
    ${week}
    `, '
    ', '
    ', '
    加星 / 置顶
    ', `
    ${starred} / ${pinned}
    `, '
    ', '
    ', '
    ', '
    ', // 快捷操作卡片 '
    ', '
    ', '
    ⚡
    ', '
    快捷操作
    ', '
    ', '
    ', '
    ', '', lastId ? '' : '', '', '
    ', `
    ${untagged} 条未打标签
    `, '
    ', '
    ', // 最近笔记卡片 '
    ', '
    ', '
    🕒
    ', '
    最近笔记
    ', '
    ', '
    ', buildNotesList(recent.slice(0, 8), '暂无最近笔记'), '
    ', '
    ', // 重点笔记卡片 '
    ', '
    ', '
    ⭐
    ', '
    重点笔记
    ', '
    ', '
    ', buildNotesList(mergedImportant.slice(0, 4), '暂无重点笔记'), '
    ', '
    ', // 标签概览卡片 '
    ', '
    ', '
    🏷️
    ', '
    标签管理
    ', '
    ', '
    ', buildTags(), '
    ', '
    ', // 附件卡片 '
    ', '
    ', '
    📎
    ', '
    附件中心
    ', '
    ', '
    ', buildAttachments(), '
    ', '
    ', '
    ' ].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 += `${escapeHtml(args[0])}` 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 r = await fetch('/api/cloud_notes/auth/local_creds', { headers: token ? { 'X-Local-Creds-Token': token } : {}, 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())