1/* Active application controller. Existing PDF, patent and weather adapters live in app.js. */ 2(() => { 3 'use strict'; 4 const { localDate, dateWindow, healthRecord, healthInRange } = LabCore; 5 const esc = escapeHtml; 6 const main = document.querySelector('#mainContent'); 7 const modalRoot = document.querySelector('#modalRoot'); 8 const icon = name => `<i data-lucide="${name}" aria-hidden="true"></i>`; 9 const action = (name, label, symbol, style = 'secondary-button', extra = '') => `<button type="button" class="${style}" data-do="${name}" ${extra}>${symbol ? icon(symbol) : ''}${label}</button>`; 10 const tool = (name, label, symbol, extra = '') => action(name, '', symbol, 'icon-button', `aria-label="${label}" title="${label}" ${extra}`); 11 const empty = text => `<div class="quiet-empty">${esc(text)}</div>`; 12 const pageHead = (title, actions = '', description = '') => `<div class="page-heading compact-heading"><div><h1>${esc(title)}</h1>${description ? `<p>${esc(description)}</p>` : ''}</div><div class="heading-actions">${actions}</div></div>`; 13 const formats = { bloodPressure: ['è¡å', 'mmHg'], weight: ['ä½é', 'kg'], steps: ['æ¥è¡', 'æ¥'], water: ['饮水', 'mL'], symptom: ['çç¶å¤æ³¨', ''] }; 14 let chart, searchController, modalFocus, photoViewer; 15 let healthDays = '31', healthType = 'bloodPressure', noteQuery = '', favoriteOnly = false; 16 let galleryPending = null; 17 let homeYear = '', homeCategory = '', homeQuery = '', disposeFilmstrip; 18 let guestbookItems = [], guestbookLoaded = false, guestbookPending = null, guestbookError = '', guestbookPage = 0, guestbookYear = null, disposeGarden; 19 const publicHome = () => state.currentSpace === 'landing' && state.view === 'home'; 20 Object.assign(galleryState, { albums: [], loaded: false, admin: false, managementEnabled: false, trash: false, limit: 60 }); 21 22 function icons() { if (window.lucide) lucide.createIcons({ attrs: { 'stroke-width': 1.7 } }); } 23 function read(key, fallback) { 24 try { return JSON.parse(localStorage.getItem(key) || 'null') ?? fallback; } 25 catch { return fallback; } 26 } 27 function write(key, value) { 28 try { localStorage.setItem(key, JSON.stringify(value)); } 29 catch { throw new Error('æµè§å¨åå¨ç©ºé´ä¸è¶³æè¢«ç¦ç¨ã请å 导åºå¤ä»½ï¼æ¬æ¬¡æ´æ¹å°æªä¿åã'); } 30 } 31 const noteKey = () => 'zhixing-notes-' + state.currentSpace; 32 const notes = () => { const rows = read(noteKey(), []); return Array.isArray(rows) ? rows : []; }; 33 const uid = () => crypto.randomUUID(); 34 const urlSafe = url => { try { const parsed = new URL(url, location.href); return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : ''; } catch { return ''; } }; 35 function goExternal(url) { const target = urlSafe(url); if (target) window.open(target, '_blank', 'noopener,noreferrer'); else toast('没æå¯æå¼çç½é¡µå°å'); } 36 async function api(path, body, method = 'POST', signal) { 37 let response; 38 try { 39 response = await fetch(paperApiBase() + path, { method, credentials: 'same-origin', cache: 'no-store', signal, 40 ...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) }); 41 } catch (error) { 42 if (error.name === 'AbortError') throw error; 43 throw new Error('è¿æ¥ä¸æï¼è¯·æ£æ¥ç½ç»æçå¾ æå¡å¯å¨åéè¯ã'); 44 } 45 const data = await response.json().catch(() => ({})); 46 if (!response.ok) { 47 if (response.status === 401) galleryState.admin = false; 48 throw new Error(data.error || `æå¡è¿å ${response.status}ï¼è¯·ç¨åéè¯ã`); 49 } 50 return data; 51 } 52 53 savePapers = function () { 54 write('zhixing-papers', papers.map(p => { 55 const copy = { ...p }; 56 for (const key of ['pageImages', 'pdfBase64', 'localPdfUrl']) delete copy[key]; 57 return copy; 58 })); 59 }; 60 saveProjectGroups = () => write('zhixing-project-groups', projectGroups); 61 getHealth = function () { 62 const raw = read(healthKey(), {}); 63 return { ...raw, metrics: raw.metrics || {}, medications: Array.isArray(raw.medications) ? raw.medications : [], timeline: Array.isArray(raw.timeline) ? raw.timeline : [] }; 64 }; 65 saveHealth = data => write(healthKey(), data); 66 function todayMetrics() { 67 const result = {}; 68 for (const row of getHealth().timeline) if (row.date === localDate() && row.type && result[row.type] === undefined) result[row.type] = row.secondary === undefined ? row.value : `${row.value}/${row.secondary}`; 69 return result; 70 } 71 72 function routeHash() { 73 if (state.view === 'guestbook') return '#/guestbook' + (state.guestbookYearRoute ? '?year=' + encodeURIComponent(state.guestbookYearRoute) : ''); 74 const params = new URLSearchParams(); 75 if (state.view === 'gallery') { 76 if (state.albumId) params.set('album', state.albumId); 77 if (state.galleryYear) params.set('year', state.galleryYear); 78 return '#/gallery' + (params.size ? '?' + params : ''); 79 } 80 if (state.view === 'papers') { 81 if (state.selectedPaper) params.set('paper', state.selectedPaper); 82 if (state.projectGroup) params.set('group', state.projectGroup); 83 } 84 if (state.view === 'search' && state.searchQuery) params.set('q', state.searchQuery); 85 return `#/${state.currentSpace}/${state.view}` + (params.size ? '?' + params : ''); 86 } 87 function navigate(view, space = state.currentSpace) { 88 closePaperModal(); 89 if (view === 'guestbook') space = 'landing'; 90 else if (view === 'gallery') space = 'gallery';
91 else if (space === 'gallery') space = view === 'home' ? 'landing' : 'zhang'; 92 else if (space === 'landing' && !['home', 'gallery'].includes(view)) space = 'zhang'; 93 state.currentSpace = space; state.view = view; 94 state.paperScope = space === 'shared' ? 'shared' : state.projectGroup ? 'project' : 'all'; 95 document.querySelector('#profileMenu').hidden = true; 96 document.querySelector('#profileSwitcher').setAttribute('aria-expanded', 'false'); 97 setMobileSidebar(false); 98 const hash = routeHash(); 99 if (location.hash !== hash) history.pushState(null, '', hash); 100 write('zhixing-last-route', hash); 101 renderView(); 102 window.scrollTo({ top: 0, behavior: 'instant' }); 103 if (publicHome() || (['gallery', 'shared'].includes(view) && !galleryState.loaded)) loadGallery(); 104 if (view === 'guestbook' && !guestbookLoaded) loadGuestbook(); 105 } 106 setView = view => navigate(view); 107 setSpace = space => { state.projectGroup = ''; state.paperQuery = ''; navigate(space === 'gallery' ? 'gallery' : space === 'shared' ? 'shared' : 'home', space); }; 108 selectPaper = id => { state.selectedPaper = id; state.readerTab = 'original'; navigate('papers'); }; 109 openProjectGroup = id => { state.projectGroup = id; state.paperQuery = ''; state.selectedPaper = papers.find(p => projectForPaper(p) === id)?.id || ''; navigate('papers'); }; 110 const inferProject = projectForPaper; 111 projectForPaper = p => p.projectId || inferProject(p); 112 113 function navigation() { 114 document.querySelectorAll('.shared-extra-nav').forEach(el => el.remove()); 115 const gallery = state.view === 'gallery'; 116 const home = publicHome(); 117 const guestbook = state.view === 'guestbook'; 118 document.body.classList.toggle('public-mode', gallery || home || guestbook); 119 document.body.classList.toggle('guestbook-mode', guestbook); 120 document.body.classList.toggle('album-home-mode', home); 121 document.querySelectorAll('.public-nav a').forEach(link => { 122 const active = home ? link.hash === '#/landing/home' : guestbook ? link.hash === '#/guestbook' : gallery && link.hash === '#/gallery'; 123 if (active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current'); 124 }); 125 document.body.classList.toggle('gallery-mode', gallery); 126 document.body.classList.toggle('papers-view', state.view === 'papers'); 127 document.querySelector('#currentSpaceLabel').textContent = gallery ? 'å®éªå®¤ç¸å' : currentLabel(); 128 document.querySelector('#sidebarPersonName').textContent = state.currentSpace === 'landing' ? 'ç¥è¡å®éªå®¤' : currentLabel(); 129 document.querySelector('#sidebarPersonRole').textContent = gallery ? 'æ¯ä¸ä¸å®éªå®¤è®°å¿' : 'ç ç©¶ä¸çæ´»'; 130 document.querySelector('#sidebarAvatar').textContent = gallery ? 'ç¸' : state.currentSpace === 'shared' ? 'å ±' : person().avatar; 131 document.querySelector('#currentPersonCard').hidden = gallery; 132 document.querySelector('.sidebar-footer').hidden = false; 133 document.querySelectorAll('.side-nav .nav-label').forEach(el => el.hidden = gallery || state.currentSpace === 'shared'); 134 document.querySelectorAll('.side-nav [data-view]').forEach(el => { 135 const allowed = gallery ? ['gallery', 'home'] : state.currentSpace === 'shared' ? ['shared', 'papers', 'gallery', 'guestbook'] : null; 136 el.hidden = !!allowed && !allowed.includes(el.dataset.view); 137 el.classList.toggle('active', el.dataset.view === state.view); 138 if (el.dataset.view === state.view) el.setAttribute('aria-current', 'page'); else el.removeAttribute('aria-current'); 139 }); 140 document.title = (gallery ? 'å®éªå®¤ç¸å' : ({ home: '仿¥å·¥ä½å°', papers: 'æç®é 读', projects: 'æç®åç»', health: 'å¥åº·è®°å½', notes: 'ç¬è®°ä¸æ¶è', settings: '设置', search: 'æç´¢', help: '使ç¨è¯´æ' }[state.view] || 'ç ç©¶ä¸çæ´»')) + ' · ç¥è¡å®éªå®¤'; 141 document.querySelector('.global-search').hidden = gallery; 142 if (home) document.title = 'å®éªå®¤ç¸å · ç¥è¡å®éªå®¤';
143 if (guestbook) document.title = 'çè¨å¢ · ç¥è¡å®éªå®¤'; 144 } 145 setMobileSidebar = function (open) { 146 const mobile = matchMedia('(max-width: 900px)').matches; 147 document.querySelector('#app').classList.toggle('mobile-sidebar-open', mobile && open); 148 document.body.classList.toggle('mobile-sidebar-open', mobile && open); 149 document.querySelector('#sidebarBackdrop').setAttribute('aria-hidden', String(!mobile || !open)); 150 const sidebar = document.querySelector('.sidebar'); 151 sidebar.inert = mobile && !open; 152 document.querySelector('#sidebarToggle').setAttribute('aria-expanded', String(mobile && open)); 153 }; 154 155 function showModal(title, content, wide = false) { 156 modalFocus = document.activeElement; 157 modalRoot.innerHTML = `<div class="modal-backdrop" data-close-modal><section role="dialog" aria-modal="true" aria-labelledby="activeModalTitle" class="modal-card ${wide ? 'wide-modal' : ''}"><div class="modal-header"><h2 id="activeModalTitle">${esc(title)}</h2>${tool('close', 'å ³é', 'x')}</div>${content}<p class="form-error" id="modalError" role="alert"></p></section></div>`; 158 document.querySelector('#app').inert = true; 159 document.body.classList.add('modal-open'); 160 icons(); 161 modalRoot.querySelector('input:not([type=hidden]),select,textarea,button')?.focus(); 162 } 163 closePaperModal = function () { 164 searchController?.abort(); 165 searchController = null; 166 modalRoot.innerHTML = ''; 167 document.querySelector('#app').inert = false; 168 document.body.classList.remove('modal-open'); 169 if (modalFocus?.isConnected) modalFocus.focus(); 170 }; 171 const modalActions = (label = 'ä¿å') => `<div class="modal-actions">${action('close', 'åæ¶', '')}<button type="submit" class="primary-button">${label}</button></div>`; 172 173 function displaySettings() { 174 const value = SiteDisplay.get(); 175 const options = (group, values) => values.map(([key, label]) => `<label class="reading-choice"><input type="radio" name="${group}" value="${key}" ${value[group] === key ? 'checked' : ''}>
175<span>${label}</span></label>`).join(''); 176 showModal('æ¾ç¤ºè®¾ç½®', `<div class="reading-settings"><fieldset><legend>æ¾ç¤ºæ¨¡å¼</legend><div class="reading-options">${options('theme', [['default','é»è®¤'],['comfort','æ¤ç¼']])}</div></fieldset><fieldset><legend>æå大å°</legend><div class="reading-options">${options('size', [['standard','æ å'],['larger','è¾å¤§'],['large','大']])}</div></fieldset><fieldset><legend>è±åå¨ç»</legend><div class="reading-options">${options('motion', [['auto','èªå¨'],['static','éæ']])}</div><p class="form-help">éææ¨¡å¼ä¿çè±æ¯ï¼åæ¢è±æåå¡çæå¨ãèªå¨æ¨¡å¼éµå¾ªç³»ç»çåå°å¨æææè®¾ç½®ã</p></fieldset><div class="reading-sample"><strong>让åå¿ï¼ç徿´æ¸ æ¥ã</strong><p>ç §ççä½ç¸èï¼çè¨æ¥ç说ã</p></div><p class="form-help" id="readingStatus" role="status">设置ç«å³çæï¼å¹¶å¨å½åæµè§å¨è®°ä½éæ©ãç §çä¿æåè²ï¼PDFå¾çå¯éè¿é 读å¨ç¼©æ¾ã</p><div class="reading-actions">${action('reset-display','æ¢å¤é»è®¤','','secondary-button')}${action('close','宿','','primary-button')}</div></div>`); 177 } 178 function syncDisplayControls() { 179 const value = SiteDisplay.get(); 180 modalRoot.querySelectorAll('.reading-settings input[type=radio]').forEach(input => { input.checked = value[input.name] === input.value; }); 181 } 182 window.addEventListener('display-preferences-changed', syncDisplayControls); 183 document.addEventListener('change', event => { 184 const input = event.target; 185 if (!input.matches('.reading-settings input[type=radio]')) return; 186 const saved = SiteDisplay.set({ ...SiteDisplay.get(), [input.name]: input.value }); 187 document.querySelector('#readingStatus').textContent = saved ? 'å·²ä¿åï¼å ¶ä»é¡µé¢ä¹ä¼ä½¿ç¨è¿ä¸ªè®¾ç½®ã' : 'æ¬æ¬¡å·²çæï¼æµè§å¨åå¨ä¸å¯ç¨ï¼å ³é页é¢åå¯è½æ æ³è®°ä½ã'; 188 }); 189 190 // Navigation can wrap with larger text; keep the sidebar below its real height. 191 const headerObserver = new ResizeObserver(entries => { 192 document.documentElement.style.setProperty('--topbar-height', `${Math.ceil(entries[0].target.getBoundingClientRect().height)}px`); 193 }); 194 headerObserver.observe(document.querySelector('.topbar')); 195 196 renderHome = function () { 197 const recent = papers.slice(0, 4), currentNotes = notes().slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 3); 198 const metrics = todayMetrics(); 199 return pageHead(`${person().name}ï¼ä»å¤©å¥½`, action('online', 'æ¥è¯¢æç®', 'search', 'primary-button') + action('new-note', 'åç¬è®°', 'square-pen'), new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(new Date())) + 200 `<div class="dashboard-stats"><div><span>æç®</span><strong>${papers.length}</strong></div><div><span>ç¬è®°</span><strong>${notes().length}</strong></div><div><span>æç®åç»</span><strong>${projectGroups.length}</strong></div><div><span>å¥åº·è®°å½</span><strong>${getHealth().timeline.length}</strong></div></div>` + 201 `<div class="workspace-columns"><section><div class="section-title"><h2>æè¿æç®</h2><button class="text-button" data-view="papers">å ¨é¨æç® â</button></div>${recent.length ? recent.map(p => `<button class="record-row" data-paper="${esc(p.id)}"><span>${icon('file-text')}<strong>${esc(p.zh || p.title)}</strong><small>${esc(p.meta || '')}</small></span>${icon('chevron-right')}</button>`).join('') : empty('è¿æ²¡æä¿åçæç®')}<div class="section-title"><h2>æè¿ç¬è®°</h2><button class="text-button" data-view="notes">å ¨é¨ç¬è®° â</button></div>${currentNotes.length ? currentNotes.map(n => `<button class="record-row" data-do="edit-note" data-id="${esc(n.id)}"><span><strong>${esc(n.title)}</strong><small>${esc(n.updatedAt.slice(0, 10))}</small></span>${icon('chevron-right')}</button>`).join('') : empty('è¿æ²¡æç¬è®°')}</section><section><div class="section-title"><h2>仿¥è®°å½</h2><button class="text-button" data-view="health">å¥åº·è®°å½ â</button></div><div class="today-grid">${Object.entries(formats).filter(([key]) => key !== 'symptom').map(([key, [label, unit]]) => `<div><span>${label}</span><strong>${metrics[key] === undefined ? 'æªè®°å½' : esc(metrics[key])}</strong><small>${metrics[key] === undefined ? '' : unit}</small></div>`).join('')}</div>${action('health-record', 'æ·»å è®°å½', 'plus')}<div class="section-title"><h2>å®éªå®¤ç¸å</h2></div><a class="album-entry" href="#/gallery">${icon('images')}<span><strong>æ¯ä¸ä¸å®éªå®¤è®°å¿</strong><small>
201æ¯ä¸åå½± · å®éªå®¤æ¥å¸¸ · æ´»å¨</small></span>${icon('arrow-up-right')}</a></section></div>`; 202 }; 203 function homeAlbums() { 204 const query = homeQuery.trim().toLowerCase(); 205 return galleryState.albums.filter(a => (!homeYear || a.year === homeYear) && (!homeCategory || a.category === homeCategory) && `${a.title} ${a.description || ''} ${a.category || ''} ${a.year || ''}`.toLowerCase().includes(query)); 206 } 207 function homeCatalog() { 208 if (galleryState.error) return `<div class="home-empty" role="alert"><h3>ç¸åææ¶æªè½å è½½</h3><p>${esc(galleryState.error)}</p>${action('refresh-gallery', 'éæ°å è½½', 'refresh-cw')}</div>`; 209 if (!galleryState.loaded) return '<div class="home-empty" role="status"><p>æ£å¨æå¼å®éªå®¤çåå¿â¦</p></div>'; 210 const albums = homeAlbums(); 211 if (!albums.length) return `<div class="home-empty"><h3>${galleryState.albums.length ? 'æ²¡ææ¾å°å¯¹åºçç¸å' : 'çä¸ä¸ªä½ç½®ï¼ç»ä¸ä¸æ¬¡ç¸è'}</h3><p>${galleryState.albums.length ? 'è¯è¯å ¶ä»å¹´ä»½ãæ´»å¨ç±»åæå ³é®è¯ã' : 'ç¸ååå¸åï¼æ¯ä¸å影䏿¥å¸¸ç §çä¼åºç°å¨è¿éã'}</p>${galleryState.albums.length ? action('reset-home-filters', 'æ¥çå ¨é¨ç¸å', 'images') : action('manage-gallery', '管çç¸å', 'plus')}</div>`; 212 return `<div class="home-album-grid">${albums.map(albumCard).join('')}</div>`; 213 } 214 function refreshHomeCatalog() { 215 const catalog = document.querySelector('#homeAlbumResults'); 216 if (catalog) catalog.innerHTML = homeCatalog(); 217 const count = document.querySelector('#homeAlbumCount'); 218 if (count) count.textContent = `${homeAlbums().length} 个ç¸å`; 219 document.querySelectorAll('[data-do="home-category"]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.category === homeCategory))); 220 document.querySelectorAll('[data-do="home-year"]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.year === homeYear))); 221 const yearSelect = document.querySelector('#homeAlbumYear'); 222 if (yearSelect) yearSelect.value = homeYear; 223 const film = document.querySelector('#homeFilmstrip'); 224 if (film) { 225 disposeFilmstrip?.(); disposeFilmstrip = null; 226 film.innerHTML = renderHomeFilmstrip(homePhotos()); 227 bindFilmstrip(); 228 } 229 icons(); 230 } 231 function homePhotos() { 232 const albums = new Set(homeAlbums().map(a => a.id)); 233 return galleryState.items.filter(p => !p.deletedAt && albums.has(p.albumId)).slice(0, 18); 234 } 235 function renderHomeFilmstrip(photos) { 236 return photos.length ? `<div class="filmstrip-shell" role="region" aria-label="ç²¾éå®éªå®¤ç §ç"><div class="filmstrip" tabindex="0" aria-roledescription="è½®æå¾" aria-label="å®éªå®¤ç §çï¼ä½¿ç¨æ¹åé®åæ¢ï¼ç¹å»ä¸å¤®ç §çæå¼ç¸å" aria-describedby="filmInstructions"><div class="film-deck">${photos.map((photo, i) => { 237 const album = galleryState.albums.find(a => a.id === photo.albumId); 238 return `<a class="film-frame" data-photo-index="${i}" href="#/gallery?album=${encodeURIComponent(photo.albumId)}" aria-label="æå¼${esc(album.title)}ï¼${esc(photo.title)}"><div class="film-image"><img src="${esc(photo.thumbnailUrl || photo.url)}" alt="${esc(photo.title)}" width="${photo.width || 1200}" height="${photo.height || 800}" ${i < 3 ? 'fetchpriority="high"' : 'loading="lazy"'} decoding="async"><span class="film-open">æå¼ç¸å ${icon('arrow-up-right')}</span></div><div class="film-caption"><div><span>${esc(album.category || 'å®éªå®¤è®°å¿')}${album.year ? ' / ' + esc(album.year) : ''}</span><strong>${esc(photo.title || album.title)}</strong></div><span class="frame-index">${String(i + 1).padStart(2, '0')}</span></div></a>`; 239 }).join('')}</div></div><div class="film-controls"><span id="filmInstructions"><span class="film-desktop-hint">ç§»å¨é¼ æ æµè§ · ç¹å»ä¸å¤®ç §çæå¼ç¸å</span><span class="film-mobile-hint">左峿»å¨åæ¢ Â· ç¹å»ä¸å¤®ç §çæå¼ç¸å</span></span><div><button type="button" data-do="film-prev" class="film-arrow" aria-label="ä¸ä¸å¼ ç §ç">${icon('arrow-left')}</button><span id="filmPosition" aria-label="å½åç §ç">1 / ${photos.length}</span><button type="button" data-do="film-next" class="film-arrow" aria-label="ä¸ä¸å¼ ç §ç">${icon('arrow-right')}</button><button type="button" class="film-motion" data-do="film-motion" aria-pressed="false">æåèªå¨æµè§</button></div></div></div>` : `<div class="hero-wait" role="status">${icon('images')}<span>${galleryState.error ? 'åå¿ç¨ååè§ï¼è¯è¯ä¸æ¹çéæ°å è½½ã' : galleryState.loaded ? (galleryState.albums.length ? 'å½åçé䏿²¡æç §çï¼è¯è¯å ¶ä»å¹´çº§æå ³é®è¯ã' : '第ä¸å¼ ç §çï¼çå¾ ä¸ä½ éé¢ã') : 'æ£å¨å 载精éç §çâ¦'}</span></div>`; 240 } 241 renderLanding = function () { 242 const years = [...new Set(galleryState.albums.map(a =>
242 a.year).filter(Boolean))].sort().reverse(); 243 const categories = [...new Set(galleryState.albums.map(a => a.category).filter(Boolean))]; 244 if (categories.length < 2) homeCategory = ''; 245 const photos = homePhotos(); 246 return `<div class="memory-home"> 247 <section class="memory-hero" aria-labelledby="memoryTitle"> 248 <div class="memory-intro"><span class="memory-kicker">ç¥è¡ · å ±åçæ¶å </span><h1 id="memoryTitle">é£äºä¸èµ·èµ°è¿çæ¥å<span>é½çå¨è¿éã</span></h1><p>ä»å®éªå®¤çæ¥å¸¸ï¼å°æ¯ä¸æ¶çåå½±ã<br class="mobile-break">欢è¿åæ¥ï¼ç¿»ç¿»æä»¬çç¸åã</p><button class="memory-cta" type="button" data-do="browse-home-albums">å¯»æ¾æçåå¿ ${icon('arrow-down')}</button></div> 249 <div class="film-years" role="group" aria-label="ææ¯ä¸å±å«æµè§ç §ç">${['', ...years].map(year => action('home-year', year ? esc(year) + 'å±' : 'å ¨é¨å±å«', '', 'film-year-chip', `data-year="${esc(year)}" aria-pressed="${homeYear === year}"`)).join('')}</div> 250 <div id="homeFilmstrip">${renderHomeFilmstrip(photos)}</div> 251 </section> 252 <section id="homeAlbums" class="home-albums" aria-labelledby="homeAlbumsTitle" tabindex="-1"><div class="archive-heading"><div><span class="memory-kicker">æä»¬çç¸å</span><h2 id="homeAlbumsTitle">æ¯æ¬¡ç¸èï¼<span>é½å¼å¾æ¶èã</span></h2></div><a href="#/gallery">æµè§å ¨é¨ ${icon('arrow-up-right')}</a></div> 253 <div class="home-filter-row"><label class="home-search">${icon('search')}<input id="homeAlbumQuery" type="search" placeholder="æç´¢ç¸åãæ´»å¨æåå¿" aria-label="æç´¢é¦é¡µç¸å" value="${esc(homeQuery)}"></label><label class="home-year">æ¯ä¸å±å«<select id="homeAlbumYear"><option value="">å ¨é¨å±å«</option>${years.map(year => `<option value="${esc(year)}" ${homeYear === year ? 'selected' : ''}>${esc(year)}å±</option>`).join('')}</select></label></div> 254 <div class="home-category-row"><div class="home-categories" role="group" aria-label="æ´»å¨ç±»å" ${categories.length < 2 ? 'hidden' : ''}>${['', ...categories].map(category => action('home-category', esc(category || 'å ¨é¨'), '', 'category-chip', `data-category="${esc(category)}" aria-pressed="${homeCategory === category}"`)).join('')}</div><span id="homeAlbumCount" role="status">${homeAlbums().length} 个ç¸å</span></div><div id="homeAlbumResults">${homeCatalog()}</div> 255 </section> 256 <section class="mentor-note"><div>${icon('sprout')}<span><strong>åå¿å¨è¿éï¼æ°çæ¢ç´¢ä¹å¨ç»§ç»ã</strong><small>两ä½èå¸çç ç©¶ä¸ç活空é´</small></span></div><button type="button" data-do="mentor-spaces">è¿å ¥å¯¼å¸ç©ºé´ ${icon('arrow-up-right')}</button></section> 257 <footer class="memory-footer"><span>
257ç¥è¡å®éªå®¤</span><p>èæ£ææ¶ï¼è®°å¿å¸¸å¨ã</p><a href="#/gallery">ç¸åä¸ç §ç ${icon('arrow-up-right')}</a></footer> 258 </div>`; 259 }; 260 function mentorSpaces() { 261 showModal('导å¸ç©ºé´', `<div class="mentor-picker"><p>åèªä¸æ³¨ï¼ä¹å¨å ±åçç ç©¶ä¸çæ´»ä¸ä¿æè¿æ¥ã</p><div class="mentor-options">${[['zhang', 'å¼ èå¸'], ['duan', '段èå¸']].map(([space, name]) => `<button type="button" data-space="${space}"><img src="assets/teacher-${space}.jpg" alt="" width="120" height="140"><strong>${name}</strong><span>è¿å ¥ä¸ªäººç©ºé´ ${icon('arrow-up-right')}</span></button>`).join('')}</div><button type="button" class="mentor-shared" data-space="shared">${icon('waypoints')} æä»¬çå ±åç©ºé´ ${icon('arrow-right')}</button></div>`); 262 } 263 async function loadGuestbook() { 264 if (guestbookPending) return guestbookPending; 265 guestbookError = ''; 266 guestbookPending = (async () => { 267 try { 268 const [data, session] = await Promise.all([api('/api/guestbook', undefined, 'GET'), api('/api/gallery/session', undefined, 'GET').catch(() => ({ admin: false, managementEnabled: false }))]); 269 Object.assign(galleryState, { admin: session.admin, managementEnabled: session.managementEnabled }); 270 guestbookItems = Array.isArray(data.items) ? data.items : []; 271 if (guestbookYear === null) guestbookYear = state.guestbookYearRoute === 'all' ? '' : state.guestbookYearRoute || guestbookItems.map(row => row.year).filter(Boolean).sort().reverse()[0] || ''; 272 guestbookLoaded = true; 273 } catch (error) { guestbookError = error.message; } 274 finally { guestbookPending = null; } 275 if (state.view === 'guestbook') renderView(); 276 })(); 277 return guestbookPending; 278 } 279 const guestDate = row => { 280 const date = new Date(row.createdAt); 281 return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); 282 }; 283 function renderGuestbook() { 284 const years = [...new Set([...guestbookItems.map(row => row.year), guestbookYear !== 'unspecified' ? guestbookYear : ''].filter(Boolean))].sort().reverse(); 285 const filtered = guestbookItems.filter(row => !guestbookYear || (guestbookYear === 'unspecified' ? !row.year : row.year === guestbookYear)); 286 const sorted = filtered.slice().sort((a,b) => Number(!!b.pinned)-Number(!!a.pinned) || String(b.createdAt).localeCompare(String(a.createdAt))); 287 const pages = Math.max(1, Math.ceil(sorted.length / 6)); 288 guestbookPage = Math.min(guestbookPage, pages - 1); 289 const items = sorted.slice(guestbookPage * 6, guestbookPage * 6 + 6); 290 const card = (row, i) => `<div class="garden-note-anchor" data-slot="${i}" style="--slot:${i};--column:${i%3};--row:${Math.floor(i/3)};--tilt:${[-1,.8,-.6,.7,-.8,.5][i]}deg"> 291 <button type="button" class="garden-note" data-do="open-guestbook" data-id="${esc(row.id)}" aria-label="é 读${esc(row.name)}ççè¨"> 292 <span class="note-eyebrow">${row.pinned ? '置顶çè¨' : row.owned ? 'æççè¨' : 'åç»å¯¼å¸'}<span>${row.year ? esc(row.year)+' å±' : 'å®éªå®¤çæå'}</span></span> 293 <span class="note-message">${esc(row.message)}</span> 294 <span class="note-signature"><strong>${esc(row.name)}</strong><time>${guestDate(row)}${row.updatedAt ? ' · å·²ç¼è¾' : ''}</time></span> 295 <span class="note-bottom"><span>${row.reply ? '导å¸å·²åå¤ Â· ' : ''}${row.liked ? 'â¥' : 'â¡'} ${Number(row.likes)||0}</span><span>å±å¼é 读 â</span></span> 296 </button></div>`; 297 let beds = ''; 298 if (items.length) beds = `<section class="garden-bed" aria-label="è°è¡£èè±ä¸ä¸ççè¨" data-count="${items.length}" style="--note-count:${items.length}"><div class="garden-fallback" aria-hidden="true"></div><canvas class="garden-canvas" aria-hidden="true"></canvas><div class="garden-notes">${items.map(card).join('')}</div></section>`; 299 if (!items.length) beds = `<section class="garden-bed garden-empty" data-count="0"><div class="garden-fallback" aria-hidden="true"></div><canvas class="garden-canvas" aria-hidden="true"></canvas><div class="garden-status" role="status"><h2>${guestbookError ? 'çè¨ææ¶æªè½å è½½' : guestbookLoaded ? (guestbookYear ? `${guestbookYear === 'unspecified' ? 'æªå¡«å±å«' : guestbookYear+' å±'}è¿æ²¡æçè¨` : '第ä¸å¥è¯ï¼ä¼å¨è¿éçæ ¹ã') : 'æ£å¨æå¼çè¨å¢â¦'}</h2><p>${guestbookError ? esc(guestbookError) : guestbookLoaded ? 'çä¸å¥ç¥ç¦ï¼æå享æ¯ä¸åçè¿åµã' : 'ç¨ççå»ï¼åå¿æ£å¨è·¯ä¸ã'}</p>${guestbookError ? action('refresh-guestbook','éæ°å è½½','refresh-cw') : guestbookLoaded ? action('new-guestbook','åä¸ç¬¬ä¸æ¡çè¨','plus') : ''}</div></section>`; 300 const yearChip = (value, label, count) => action('guestbook-year',`${label} · ${count}`,'','garden-year',`data-year="${esc(value)}" aria-pressed="${(guestbookYear || '') === value}"`); 301 const filters = `<nav class="garden-years" aria-label="ææ¯ä¸å±å«æ¥ççè¨">${yearChip('','å ¨é¨å±å«',guestbookItems.length)}${years.map(year=>yearChip(year,`${year} å±`,guestbookItems.filter(row=>row.year===year).length)).join('')}${guestbookItems.some(row=>!row.year) ? yearChip('unspecified','æªå¡«å±å«',guestbookItems.filter(row=>!row.year).length) : ''}</nav>`; 302 return `<div class="guestbook-garden"><header class="garden-heading"><div><span class="garden-kicker">ç¥è¡ · çè¨å¢ <i></i>
302 WORDS THAT GROW</span><h1>ææ³å¿µï¼ç卿头ã</h1><p>ç»å¯¼å¸çä¸å¥æè°¢ï¼æ¯ä¸åçä¸ç¹è¿åµã</p></div><div class="garden-invitation">${action('new-guestbook','åä¸çè¨','pen-line','garden-write')}</div></header>${filters}<div class="garden-toolbar"><span>${guestbookLoaded ? `${filtered.length} ä»½å¿æï¼æ£å¨çé¿` : 'æ¯ä¸çççè¨è±å'}</span><div class="garden-management">${action('guestbook-admin','çè¨ç®¡ç','','text-button')}${galleryState.admin ? action('guestbook-trash','åæ¶ç«','archive','text-button') + action('guestbook-logout','éåºç®¡ç','','text-button') : ''}</div></div>${guestbookError && items.length ? `<p class="garden-error" role="alert">${esc(guestbookError)} ${action('refresh-guestbook','éè¯','refresh-cw')}</p>` : ''}${beds}<footer class="garden-footer"><span>åèªè¿è¡ï¼ä¹å¨è¿éç¸èã</span>${pages > 1 ? `<nav aria-label="çè¨å页">${action('guestbook-page','ä¸ä¸é¡µ','chevron-left','secondary-button',`data-page="${guestbookPage-1}" ${guestbookPage===0?'disabled':''}`)}<span>${guestbookPage+1} / ${pages}</span>${action('guestbook-page','ä¸ä¸é¡µ','chevron-right','secondary-button',`data-page="${guestbookPage+1}" ${guestbookPage===pages-1?'disabled':''}`)}</nav>` : '<span>
302ç¥è¡å®éªå®¤ · çç»å½¼æ¤çè®°å¿</span>'}</footer></div>`; 303 } 304 function openGuestbook(id) { 305 const row = guestbookItems.find(item => item.id === id); 306 if (!row) return; 307 showModal('æå¤´ççè¨', `<article class="garden-letter"><div class="letter-meta"><strong>${esc(row.name)}</strong><span>${row.year ? esc(row.year)+' å± Â· ' : ''}${guestDate(row)}${row.updatedAt ? ' · å·²ç¼è¾' : ''}</span></div><p>${esc(row.message)}</p>${row.reply ? `<blockquote><strong>${esc(row.replyAuthor || '导å¸')}åå¤</strong><p>${esc(row.reply)}</p></blockquote>` : ''}<div class="letter-actions"><button type="button" class="secondary-button" data-do="like-guestbook" data-id="${esc(row.id)}" aria-label="ä¸ºè¿æ¡çè¨ç¹èµ" ${row.liked ? 'disabled' : ''}>${row.liked ? 'â¥' : 'â¡'} <span data-like-count>${Number(row.likes)||0}</span> · ${row.liked ? 'å·²éåºå¿æ' : 'éä¸ä»½å¿æ'}</button>${action(galleryState.admin ? 'reply-guestbook' : 'guestbook-admin',galleryState.admin ? (row.reply ? 'ä¿®æ¹åå¤' : 'åå¤è¿æ¡çè¨') : '导å¸ç»å½åå¤','message-square','text-button',`data-id="${esc(row.id)}"`)}${row.owned ? action('edit-guestbook','ä¿®æ¹çè¨','pencil','text-button',`data-id="${esc(row.id)}"`) : ''}${row.owned || galleryState.admin ? action('delete-guestbook','å é¤çè¨','trash-2','text-button danger-text',`data-id="${esc(row.id)}"`) : ''}</div></article>`); 308 } 309 async function newGuestbook(id = '') { 310 if (!guestbookLoaded) await loadGuestbook(); 311 if (!galleryState.loaded) await loadGallery(); 312 const row = id ? guestbookItems.find(item => item.id === id && item.owned) : null; 313 if (id && !row) return; 314 const years = [...new Set([...guestbookItems.map(row=>row.year), ...galleryState.albums.map(row=>row.year)].filter(Boolean))].sort().reverse(); 315 const year = row ? row.year : (/^\d{4}$/.test(guestbookYear || '') ? guestbookYear : ''); 316 showModal(row ? 'ä¿®æ¹æççè¨' : 'åä¸çè¨',`<form id="guestbookForm" data-id="${esc(id)}" class="paper-form">${row ? `<input type="hidden" name="revision" value="${esc(row.updatedAt || row.createdAt)}">` : ''}<label>æµç§°<input name="name" maxlength="40" required value="${esc(row?.name || '')}"></label><label>æ¯ä¸å±å«ï¼éå¡«ï¼<input name="year" list="guestbookYearOptions" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" value="${esc(year)}" placeholder="鿩已æå±å«ï¼æè¾å ¥æ°å±å«"><datalist id="guestbookYearOptions">${years.map(year=>`<option value="${esc(year)}">${esc(year)} å±</option>`).join('')}</datalist></label><label>çè¨<textarea name="message" aria-label="çè¨" rows="6" maxlength="1200" required placeholder="åä¸ä½ æ³å¯¹å¯¼å¸è¯´çè¯">${esc(row?.message || '')}</textarea></label><p class="form-help">${row ? 'ä¿ååæ¾ç¤ºâå·²ç¼è¾âï¼å·²æç导å¸åå¤åç¹èµä¼ä¿çã' : 'åå¸åå¯å¨å½åæµè§å¨ä¿®æ¹æå é¤èªå·±ççè¨ãæ¢è®¾å¤ææ¸ é¤ç½ç«æ°æ®åï¼è¯·è系管çåå¤çã'}</p>${modalActions(row ? 'ä¿åä¿®æ¹' : 'åå¸çè¨')}</form>`); 317 } 318 function deleteGuestbook(id) { 319 const row = guestbookItems.find(item => item.id === id); 320 if (!row || !(row.owned || galleryState.admin)) return; 321 showModal('å é¤è¿æ¡çè¨ï¼', `<form id="guestbookDeleteForm" data-id="${esc(id)}" class="paper-form"><blockquote class="reply-original"><strong>${esc(row.name)}</strong><p>${esc(row.message)}</p></blockquote><p>çè¨å导å¸åå¤ä¼ç§»å ¥åæ¶ç«ï¼ä¸åå ¬å¼æ¾ç¤ºãå¦éæ¢å¤ï¼è¯·è系管çåã</p>${modalActions('ç¡®è®¤ç§»å ¥åæ¶ç«')}</form>`); 322 } 323 function guestbookManagement() { 324 showModal('çè¨ç®¡ç', `<div class="guestbook-manager"><p>æå¼çè¨å¡å³å¯å夿å é¤çè¨ãå é¤ççè¨å¯ä»¥å¨åæ¶ç«ä¸æ¢å¤ã</p>${action('close','è¿åçè¨å¢','message-square','secondary-button')}${action('guestbook-trash','æ¥çåæ¶ç«','archive','secondary-button')}${action('guestbook-backup','导åºçè¨å¤ä»½','download','secondary-button')}<p class="form-help">å¤ä»½å å«å ¨é¨çè¨ã导å¸åå¤ãç¹èµååæ¶ç«å 容ï¼è¯·å¦¥åä¿åã</p>${action('guestbook-logout','éåºç®¡ç','log-out','text-button')}</div>`); 325 } 326 async function guestbookTrash() { 327 const data = await api('/api/gallery/guestbook/trash', undefined, 'GET'); 328 const items = (data.items || []).sort((a,b) => String(b.deletedAt).localeCompare(String(a.deletedAt)));
329 showModal('çè¨åæ¶ç«', `<div class="guestbook-trash"><p class="form-help">æ¢å¤åä¼åå°åæ¯ä¸å±å«ï¼å¯¼å¸åå¤åç¹èµä¸å¹¶ä¿çã</p>${items.length ? items.map(row=>`<article class="trash-note"><div><strong>${esc(row.name)}</strong><span>${row.year ? esc(row.year)+' å±' : 'æªå¡«å±å«'}</span></div><p>${esc(row.message)}</p><small>å é¤äº ${guestDate({createdAt:row.deletedAt})}${row.reply ? ' · å«å¯¼å¸åå¤' : ''}</small>${action('restore-guestbook','æ¢å¤çè¨','archive-restore','secondary-button',`data-id="${esc(row.id)}"`)}</article>`).join('') : '<p class="trash-empty">åæ¶ç«ä¸ºç©º</p>'}</div>`); 330 } 331 function replyGuestbook(id) { 332 const row = guestbookItems.find(item=>item.id===id); 333 if (!row) return; 334 showModal('åå¤çè¨', `<form id="guestbookReplyForm" data-id="${esc(id)}" class="paper-form"><blockquote class="reply-original"><strong>${esc(row.name)}</strong><p>${esc(row.message)}</p></blockquote><label>åå¤ç½²å<select name="replyAuthor">${['å¼ èå¸','段èå¸','导å¸'].map(name=>`<option ${name === (row.replyAuthor || (state.currentSpace==='duan'?'段èå¸':'å¼ èå¸')) ? 'selected' : ''}>${name}</option>`).join('')}</select></label><label>åå¤<textarea name="reply" rows="5" maxlength="1200" required>${esc(row.reply || '')}</textarea></label>${modalActions(row.reply ? 'ä¿ååå¤' : 'åå¸åå¤')}</form>`); 335 } 336 function bindFilmstrip() { 337 const stage = document.querySelector('.filmstrip'); 338 if (stage) disposeFilmstrip = LabFilmstrip.mount(stage); 339 } 340 document.addEventListener('input', event => { 341 if (event.target.id === 'homeAlbumQuery') { homeQuery = event.target.value; refreshHomeCatalog(); } 342 }); 343 document.addEventListener('change', event => { 344 if (event.target.id === 'homeAlbumYear') { homeYear = event.target.value; refreshHomeCatalog(); } 345 }); 346 document.addEventListener('error', event => { 347 const img = event.target; 348 if (img.tagName === 'IMG' && img.closest('.film-image, .album-cover')) { 349 img.hidden = true; 350 const message = document.createElement('span'); 351 message.className = 'photo-unavailable'; message.textContent = 'ç §çææ¶æ æ³æ¾ç¤º'; 352 img.parentElement.append(message); 353 } 354 }, true); 355 renderNotes = function () { 356 const all = notes(), list = all.filter(n => (!favoriteOnly || n.favorite) && `${n.title} ${n.content} ${n.tag}`.toLowerCase().includes(noteQuery.toLowerCase())).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); 357 return pageHead('ç¬è®°ä¸æ¶è', action('new-note', 'æ°å»ºç¬è®°', 'plus', 'primary-button')) + `<div class="filter-bar"><label>æç´¢ç¬è®°<input id="noteQuery" type="search" value="${esc(noteQuery)}" placeholder="æ é¢ãæ£æææ ç¾"></label><label class="check-label"><input id="favoriteOnly" type="checkbox" ${favoriteOnly ? 'checked' : ''}>ä» æ¶è</label><span>${list.length} / ${all.length}ç¯</span></div><div class="notes-list">${list.length ? list.map(n => `<article class="note-row"><button class="note-main" data-do="edit-note" data-id="${esc(n.id)}"><h2>${esc(n.title)}</h2><p>${esc(n.content.slice(0, 180))}</p><small>${esc(n.tag || 'æªåç±»')} · ${esc(n.updatedAt.slice(0, 10))}</small></button><div>${tool('favorite-note', n.favorite ? 'åæ¶æ¶è' : 'æ¶è', 'star', `data-id="${esc(n.id)}" aria-pressed="${!!n.favorite}"`)}${tool('delete-note', 'å é¤ç¬è®°', 'trash-2', `data-id="${esc(n.id)}"`)}</div></article>`).join('') : empty(noteQuery || favoriteOnly ? '没æç¬¦åæ¡ä»¶çç¬è®°' : 'è¿æ²¡æç¬è®°')}</div>`; 358 }; 359 function editNote(id = '') { 360 const note = notes().find(n => n.id === id) || {}; 361 showModal(id ? 'ç¼è¾ç¬è®°' : 'æ°å»ºç¬è®°', `<form id="noteForm" data-id="${esc(id)}" class="paper-form"><label>æ é¢<input name="title" required maxlength="160" value="${esc(note.title || '')}"></label><label>æ ç¾<input name="tag" maxlength="80" value="${esc(note.tag || '')}"></label><label>æ£æ<textarea name="content" rows="12" maxlength="50000" required>${esc(note.content || '')}</textarea></label>${modalActions()}</form>`, true); 362 } 363 renderProjects = function () { 364 return pageHead('æç®åç»', action('reset-groups', 'æ¢å¤é»è®¤åç§°', 'rotate-ccw')) + `<div class="project-group-grid">${projectGroups.map(g => `<article class="project-group-card"><label>åç»åç§°<input data-project-name="${esc(g.id)}" maxlength="100" value="${esc(g.title)}"></label><label>说æ<textarea data-project-desc="${esc(g.id)}" maxlength="1000">${esc(g.desc)}</textarea></label><div class="project-meta"><span>${papers.filter(p => projectForPaper(p) === g.id).length}ç¯æç®</span><button class="text-button" data-open-project="${esc(g.id)}">æ¥çæç® â</button></div></article>`).join('')}</div>`; 365 }; 366 367 renderHealth = function () { 368 const h = getHealth(), metrics = todayMetrics(), rows = healthInRange(h.timeline, healthDays); 369 return pageHead('å¥åº·è®°å½', action('health-record', 'æ·»å è®°å½', 'plus', 'primary-button'), 'è®°å½ä¿åå¨å½åæµè§å¨ï¼åå²è®°å½ä¸ä¼èªå¨å é¤ã') + 370 `<div class="health-overview">${Object.entries(formats).filter(([key]) => key !== 'symptom').map(([key, [label, unit]]) => `<div><span>仿¥${label}</span><strong>${metrics[key] === undefined ? 'æªè®°å½' : esc(metrics[key])}</strong><small>${metrics[key] === undefined ? '' : unit}</small></div>`).join('')}</div>` + 371 `<section class="section-block"><div class="section-title"><h2>仿¥ç¨è¯</h2>${action('medication', 'æ·»å ç¨è¯', 'plus')}</div>${h.medications.length ? h.medications.map((m, i) => `<div class="medication"><div><strong>${esc(m.name)}</strong><span>${esc(m.time)} · ${esc(m.note || '')}</span></div>${action('confirm-medication', (m.doneDates || []).includes(localDate()) ? '已确认' : '确认ç¨è¯', 'check', 'secondary-button', `data-index="${i}" aria-pressed="${(m.doneDates || []).includes(localDate())}"`)}${tool('delete-medication', 'å é¤ç¨è¯', 'trash-2', `data-index="${i}"`)}</div>`).join('') : empty('å°æªæ·»å ç¨è¯è®¡å')}</section>` + 372 `<section class="section-block"><div class="section-title"><h2>è®°å½ä¸è¶å¿</h2><div class="filter-bar"><label>æ¶é´èå´<select id="healthRange"><option value="31" ${healthDays === '31' ? 'selected' : ''}>æè¿31天</option><option value="all" ${healthDays === 'all' ? 'selected' : ''}>å ¨é¨åå²</option></select></label><label>è¶å¿ææ <select id="healthType">${Object.entries(formats).filter(([key]) => key !== 'symptom').map(([key, [label]]) => `<option value="${key}" ${healthType === key ? 'selected' : ''}>${label}</option>`).join('')}</select></label></div></div><div class="health-chart">
372<canvas id="healthChart" role="img" aria-label="${formats[healthType][0]}è®°å½è¶å¿"></canvas></div>${rows.length ? `<div class="health-records">${rows.map(row => `<article class="health-record"><time>${esc(row.date || String(row.createdAt || '').slice(0, 10) || 'æ¥æå¾ è¡¥')}</time><div><strong>${esc(row.title || 'åå²è®°å½')}</strong><p>${esc(row.detail || '')}</p></div>${row.type ? tool('edit-health', 'ç¼è¾è®°å½', 'pencil', `data-id="${esc(row.id)}"`) : ''}${tool('delete-health', 'å é¤è®°å½', 'trash-2', `data-index="${h.timeline.indexOf(row)}"`)}</article>`).join('')}</div>` : empty('æ¤æ¶é´èå´å 没æè®°å½')}</section>${renderWeatherPanel()}`; 373 }; 374 function healthFields(type, row = {}) { 375 if (type === 'symptom') return `<label>çç¶<textarea name="value" rows="3" maxlength="1000" required>${esc(row.value || '')}</textarea></label>`; 376 const limits = { bloodPressure: [20, 300, 1], weight: [1, 500, 0.1], steps: [0, 200000, 1], water: [0, 20000, 1] }[type]; 377 return `<div class="form-row"><label>${type === 'bloodPressure' ? 'æ¶ç¼©å' : formats[type][0]}ï¼${formats[type][1]}ï¼<input type="number" name="value" required min="${limits[0]}" max="${limits[1]}" step="${limits[2]}" value="${esc(row.value ?? '')}"></label>${type === 'bloodPressure' ? `<label>èå¼ åï¼mmHgï¼<input type="number" name="secondary" required min="10" max="299" value="${esc(row.secondary ?? '')}"></label>` : ''}</div>`; 378 } 379 function editHealth(id = '') { 380 const row = getHealth().timeline.find(r => r.id === id) || {}; 381 showModal(id ? 'ç¼è¾å¥åº·è®°å½' : 'æ·»å å¥åº·è®°å½', `<form id="healthRecordForm" class="paper-form" data-id="${esc(id)}"><div class="form-row"><label>è®°å½ç±»å<select name="type" id="recordType">${Object.entries(formats).map(([key, [label]]) => `<option value="${key}" ${row.type === key ? 'selected' : ''}>${label}</option>`).join('')}</select></label><label>æ¥æ<input name="date" type="date" required max="${localDate()}" value="${esc(row.date || localDate())}"></label></div><div id="healthFields">${healthFields(row.type || 'bloodPressure', row)}</div><label>夿³¨<textarea name="detail" rows="3" maxlength="2000">${esc(row.detail || '')}</textarea></label>${modalActions()}</form>`); 382 } 383 let chartLoading = null; 384 function loadChartLibrary() { 385 if (window.Chart) return Promise.resolve(); 386 if (!chartLoading) chartLoading = new Promise((resolve, reject) => { 387 const script = document.createElement('script'); 388 script.src = 'assets/vendor/chart.umd.js'; 389 script.onload = resolve; 390 script.onerror = () => { chartLoading = null; script.remove(); reject(new Error('å¾è¡¨ç»ä»¶æªè½å è½½')); }; 391 document.head.append(script); 392 }); 393 return chartLoading; 394 } 395 function renderTrend() { 396 const canvas = document.querySelector('#healthChart'); 397 if (!canvas) return; 398 const rows = healthInRange(getHealth().timeline, healthDays).filter(r => r.type === healthType && typeof r.value === 'number').sort((a, b) => a.date.localeCompare(b.date)); 399 if (!rows.length) { canvas.parentElement.innerHTML = empty('ææ å¯ç»å¶çæ°å¼è®°å½'); return; } 400 if (!window.Chart) { 401 if (canvas.dataset.loading) return; 402 canvas.dataset.loading = 'true'; 403 const message = document.createElement('p'); 404 message.textContent = 'æ£å¨å è½½è¶å¿å¾â¦'; message.setAttribute('role', 'status'); 405 canvas.before(message); 406 loadChartLibrary().then(() => { 407 if (document.querySelector('#healthChart') !== canvas) return; 408 message.remove(); delete canvas.dataset.loading; renderTrend(); 409 }).catch(() => { 410 if (!canvas.isConnected) return; 411 delete canvas.dataset.loading; 412 message.textContent = 'è¶å¿å¾ææ¶æªè½å è½½ï¼å¥åº·è®°å½ä»å¯æ¥çã'; 413 const retry = document.createElement('button'); retry.type = 'button'; retry.textContent = 'éæ°å è½½å¾è¡¨'; 414 retry.onclick = () => { message.remove(); renderTrend(); }; message.append(retry); 415 }); 416 return; 417 } 418 chart?.destroy(); 419 chart = new Chart(canvas, { type: 'line', data: { labels: rows.map(r => r.date), datasets: [ 420 { label: healthType === 'bloodPressure' ? 'æ¶ç¼©å' : formats[healthType][0], data: rows.map(r => r.value), borderColor: '#276879', backgroundColor: '#276879', tension: 0.15 }, 421 ...(healthType === 'bloodPressure' ? [{ label: 'èå¼ å', data: rows.map(r => r.secondary), borderColor: '#b35f70', backgroundColor: '#b35f70', tension: 0.15 }] : []) 422 ] }, options: { responsive: true, maintainAspectRatio: false, animation: false, scales: { y: { title: { display: true, text: formats[healthType][1] } } }, plugins: { legend: { position: 'bottom' } } } }); 423 } 424 425 renderPapers = function () { 426 const query = (state.paperQuery || '').toLowerCase(); 427 const rows = papers.filter(p => (!query || `${p.title} ${p.zh} ${p.meta} ${p.doi} ${p.tag}`.toLowerCase().includes(query)) && 428 (state.currentSpace !== 'shared' || p.scope === 'shared') && (!state.projectGroup || projectForPaper(p) === state.projectGroup)); 429 const selected = rows.find(p => p.id === state.selectedPaper) || rows[0]; 430 if (selected) state.selectedPaper = selected.id; 431 const group = projectGroups.find(g => g.id === state.projectGroup); 432 let detail = empty(papers.length ? '没æç¬¦åæ¡ä»¶çæç®' : 'è¿æ²¡æä¿åçæç®'); 433 if (selected) { 434 detail = `<div class="reader-toolbar"><div><strong>${esc(selected.zh || selected.title)}</strong><small>${esc(selected.meta || '')}</small></div>${tool('delete-paper', 'å 餿ç®', 'trash-2', `data-id="${esc(selected.id)}"`)}</div><div class="paper-detail-actions"><div class="subnav-tabs">${action('paper-abstract', 'æè¦', '', state.readerTab !== 'pdf' ? 'tab-button active' : 'tab-button')}${action('paper-pdf', 'PDFé 读', '', state.readerTab === 'pdf' ? 'tab-button active' : 'tab-button')}</div><label>åç»<select id="paperGroup">${projectGroups.map(g => `<option value="${esc(g.id)}" ${projectForPaper(selected) === g.id ? 'selected' : ''}>${esc(g.title)}</option>`).join('')}</select></label></div>`; 435 if (state.readerTab === 'pdf') { 436 if (selected.pageImages?.length) detail += `<div class="pdf-page-stack">${selected.pageImages.map((image, i) => `<figure><img class="pdf-page-image" src="${esc(image)}" loading="lazy" alt="PDF第${i + 1}页"><figcaption>第 ${i + 1} 页 / ${selected.pageImages.length}</figcaption></figure>`).join('')}</div>`; 437 else if (state.localPdfUrl && state.localPdfPaperId === selected.id) detail += `<iframe class="paper-pdf-frame" src="${esc(state.localPdfUrl)}" title="PDFé 读"></iframe>`; 438 else detail += `<div class="quiet-empty">${esc(selected.pdfError || 'å°æªè½½å ¥PDF')}</div>${selected.pdfUrl ? action('fetch-pdf', 'è·å弿¾PDF', 'download') : ''}${action('upload-pdf', 'ä¸ä¼ PDF', 'upload')}`; 439 } else detail += `<article class="document-page"><h2>${esc(selected.title || selected.zh)}</h2><p class="paper-doi">${esc(selected.doi || '')}</p><h3>æè¦</h3><p class="abstract-text">
439${esc(selected.abstract || 'æ¤æç®å°æ æè¦ã')}</p><div class="paper-open-actions">${selected.url ? action('open-paper', 'æå¼åæ', 'external-link') : ''}${action('upload-pdf', 'ä¸ä¼ PDF', 'upload')}${action('share-paper', selected.scope === 'shared' ? 'å·²å å ¥å ±åæç®' : 'å å ¥å ±åæç®', 'users')}</div></article>`; 440 } 441 return pageHead(group ? group.title : state.currentSpace === 'shared' ? 'å ±åæç®' : 'æç®é 读', action('online', 'å¨çº¿æ¥è¯¢', 'search', 'primary-button') + action('add-paper', 'å¯¼å ¥æç®', 'plus')) + 442 `<div class="filter-bar"><label>æç´¢æç®<input type="search" id="libraryQuery" placeholder="æ é¢ãDOIæå ³é®è¯" value="${esc(state.paperQuery || '')}"></label><span>${rows.length}ç¯</span>${state.projectGroup ? action('all-papers', 'å ¨é¨åç»', 'list') : ''}</div><div class="paper-workspace ${rows.length ? '' : 'is-empty'}"><aside class="paper-navigation" ${rows.length ? '' : 'hidden'}>${rows.map(p => `<button class="paper-entry ${selected?.id === p.id ? 'active' : ''}" data-paper="${esc(p.id)}"><strong>${esc(p.zh || p.title)}</strong><small>${esc(p.meta || '')}</small></button>`).join('')}</aside><section class="paper-reader">${detail}</section></div>`; 443 }; 444 openOnlineModal = function () { 445 const dates = dateWindow(); 446 showModal('å¨çº¿æ¥è¯¢æç®', `<form id="literatureForm" class="paper-form"><label>å ³é®è¯æDOI<input name="query" required maxlength="500" value="${esc(state.onlineQuery || 'GelMA hydrogel cell culture')}"></label><div class="form-row"><label>æ¥æº<select name="source"><option value="openalex">OpenAlex</option><option value="crossref">Crossref</option><option value="sciencedirect">ScienceDirect</option></select></label><label>æ¥æèå´<select name="range" id="literatureRange"><option value="fiveYears">è¿äºå¹´ï¼${dates.start} è³ ${dates.end}ï¼</option><option value="all">ä¸éæ¥æ</option><option value="custom">èªå®ä¹æ¥æ</option></select></label></div><div class="form-row" id="customDates" hidden><label>å¼å§æ¥æ<input name="start" type="date" value="${dates.start}"></label><label>ç»ææ¥æ<input name="end" type="date" value="${dates.end}"></label></div><div class="modal-actions"><button class="primary-button" id="searchSubmit" type="submit">å¼å§æ¥è¯¢</button>${action('cancel-search', 'åæ¶æ¥è¯¢', 'x', 'secondary-button', 'id="cancelSearch" hidden')}</div></form><div id="onlineResults" aria-live="polite">${empty('çå¾ æ¥è¯¢')}</div>`, true); 447 }; 448 searchOnlinePapers = async function (query, source, range = 'fiveYears', append = false) { 449 searchController?.abort(); 450 const controller = new AbortController(); searchController = controller; 451 const timeout = setTimeout(() => controller.abort(), 80000); 452 const box = document.querySelector('#onlineResults'); 453 if (!box) return; 454 if (!append) { state.onlinePage = 1; state.onlineResults = []; } 455 Object.assign(state, { onlineQuery: query, onlineSource: source, onlineRange: range }); 456 const submit = document.querySelector('#searchSubmit'), cancel = document.querySelector('#cancelSearch'); 457 submit.disabled = true; cancel.hidden = false; 458 box.innerHTML = '<div class="quiet-empty" role="status">æ£å¨æ¥è¯¢æç®â¦</div>'; 459 try { 460 const data = await api('/api/search-papers', { query: append ? state.onlineQueryUsed : query, source: append ? state.onlineResolvedSource : source, range, page: state.onlinePage, limit: 20 }, 'POST', controller.signal); 461 if (searchController !== controller) return; 462 const seen = new Set(state.onlineResults.map(p => p.doi || p.title)); 463 state.onlineResults.push(...(data.results || []).filter(p => !seen.has(p.doi || p.title))); 464 Object.assign(state, { onlinePage: state.onlinePage + 1, onlineHasMore: data.hasMore, onlineQueryUsed: data.queryUsed, onlineResolvedSource: data.source }); 465 renderOnlineResults(); 466 } catch (error) { 467 if (searchController !== controller) return; 468 box.innerHTML = `<div class="online-error" role="alert"><strong>${error.name === 'AbortError' ? 'æ¥è¯¢å·²åæ¶æè¶ æ¶' : 'æ¥è¯¢æªå®æ'}</strong><p>${error.name === 'AbortError' ? 'å¯ä»¥éæ°æ¥è¯¢ã' : esc(error.message)}</p>${action('retry-search', 'éè¯', 'rotate-cw')}<a class="text-button" href="https://search.crossref.org/?q=${encodeURIComponent(query)}" target="_blank" rel="noopener noreferrer">å¨ Crossref ç½ç«æ¥è¯¢</a></div>`; 469 icons(); 470 } finally { 471 clearTimeout(timeout); 472 if (searchController === controller) { searchController = null; submit.disabled = false; cancel.hidden = true; } 473 } 474 }; 475 renderOnlineResults = function () { 476 const box = document.querySelector('#onlineResults'); if (!box) return; 477 box.innerHTML = `<p class="results-summary">${state.onlineResults.length}æ¡ç»æ · ${esc(state.onlineResolvedSource || '')}${state.onlineResolvedSource !== state.onlineSource ? 'ï¼å·²åæ¢å¤ç¨æ¥æºï¼' : ''}</p>` + 478 (state.onlineResults.length ? state.onlineResults.map((p, i) => `<article class="online-result"><div><h3>${esc(p.title)}</h3><p>${esc(p.meta || '')}</p><small>${esc(p.doi || '')}</small></div><div class="online-result-actions">${action('save-online', 'ä¿åå¹¶é 读', 'bookmark-plus', 'secondary-button', `data-index="${i}"`)}${p.url ? action('open-online', 'åæ', 'external-link', 'text-button', `data-index="${i}"`) : ''}</div></article>`).join('') : empty('没æå¹é ç»æï¼è¯·è°æ´å ³é®è¯ææ¥æèå´')) + 479 (state.onlineHasMore ? action('more-online', 'å è½½æ´å¤', 'chevron-down') : ''); 480 icons(); 481 }; 482 saveOnlinePaper = function (index) { 483 const source = state.onlineResults[index]; if (!source) return; 484 let paper = papers.find(p => (source.doi && p.doi === source.doi) || p.title === source.title); 485 if (!paper) { 486 paper = { ...source, id: uid(), scope: state.currentSpace === 'shared' ? 'shared' : 'personal', status: 'å·²ä¿å', createdAt: new Date().toISOString() }; 487 papers.unshift(paper); try { savePapers(); } catch (error) { papers.shift(); throw error; } 488 } 489 state.selectedPaper = paper.id; state.readerTab = 'original'; state.paperQuery = ''; state.projectGroup = ''; navigate('papers'); 490 toast('æç®å·²ä¿å'); 491 }; 492 function addPaper() { 493 showModal('å¯¼å ¥æç®', `<form id="newPaperForm" class="paper-form"><label>æ é¢<input name="title" maxlength="500" required></label><label>DOIæåæç½å<input name="url" type="url" placeholder="https://doi.org/..."></label><label>æè¦<textarea name="abstract" rows="4" maxlength="30000"></textarea></label><label>PDFï¼å¯éï¼æå¤§30MBï¼<input name="file" type="file" accept="application/pdf,.pdf"></label>${modalActions('ä¿åæç®')}</form>`); 494 } 495 async function attachPdf(file, paper) { 496 if (file.size > 30 * 1024 * 1024) throw new Error('PDFä¸è½è¶ è¿30MB'); 497 const data = await api('/api/parse-pdf', { filename: file.name, contentBase64: bytesToBase64(await file.arrayBuffer()) }); 498 Object.assign(paper, data, { pdfName: file.name, pdfVerified: true, parsedAt: new Date().toISOString() }); 499 if (state.localPdfUrl) URL.revokeObjectURL(state.localPdfUrl); 500 state.localPdfUrl = URL.createObjectURL(file); state.localPdfPaperId = paper.id; 501 savePapers(); state.readerTab = 'pdf'; renderView(); 502 } 503 function uploadPdf() { 504 const paper = getSelectedPaper(); if (!paper) return addPaper(); 505 showModal('ä¸ä¼ PDF', `<form id="attachPdfForm" class="paper-form"><label>PDFæä»¶<input name="file" type="file" accept="application/pdf,.pdf" required></label>
505${modalActions('è§£æå¹¶é 读')}</form>`); 506 } 507 508 loadGallery = function () { 509 if (galleryPending) return galleryPending; 510 galleryState.loading = true; galleryState.error = ''; 511 galleryPending = (async () => { 512 try { 513 const data = await api('/api/gallery', undefined, 'GET'); 514 Object.assign(galleryState, data, { loaded: true }); 515 if (!data.admin) galleryState.trash = false; 516 if (galleryState.trash) galleryState.items = (await api('/api/gallery/trash', undefined, 'GET')).items; 517 } catch (error) { galleryState.error = error.message; } 518 finally { 519 galleryState.loading = false; galleryPending = null; 520 if (publicHome() || ['gallery', 'shared'].includes(state.view)) renderView(); 521 } 522 })(); 523 if (publicHome() || ['gallery', 'shared'].includes(state.view)) renderView(); 524 return galleryPending; 525 }; 526 function albumCard(album) { 527 return `<a class="album-card" href="#/gallery?album=${encodeURIComponent(album.id)}"><div class="album-cover">${album.cover ? `<img src="${esc(album.cover.thumbnailUrl)}" alt="${esc(album.title)}" loading="lazy">` : `<div class="album-placeholder">${icon('images')}</div>`}<span>${album.count} å¼ </span></div><div class="album-card-copy"><h2>${esc(album.title)}</h2><p>${esc([album.year ? album.year + 'å±' : '', album.category].filter(Boolean).join(' · '))}</p></div></a>`; 528 } 529 function photoDateLabel(photo) { 530 if (photo.dateConfirmed && photo.date) return `ææäº ${photo.date}`; 531 const uploaded = String(photo.createdAt || '').slice(0, 10); 532 return `${uploaded ? 'ä¸ä¼ äº ' + uploaded + ' · ' : ''}æææ¥æ${photo.date ? 'å¾ ç¡®è®¤' : 'æªè®°å½'}`; 533 } 534 function galleryPhotos() { 535 const query = (state.galleryQuery || '').trim().toLowerCase(); 536 return galleryState.items.filter(p => (galleryState.trash || !p.deletedAt) && 537 (!state.albumId || p.albumId === state.albumId || galleryState.trash) && 538 `${p.title || ''} ${p.description || ''} ${p.uploader || ''}`.toLowerCase().includes(query)); 539 } 540 renderGallery = function () { 541 const album = galleryState.albums.find(a => a.id === state.albumId); 542 const admin = galleryState.admin; 543 const query = (state.galleryQuery || '').toLowerCase(); 544 const albums = galleryState.albums.filter(a => (!state.galleryYear || a.year === state.galleryYear) && `${a.title} ${a.category} ${a.description}`.toLowerCase().includes(query)); 545 const photos = galleryPhotos(); 546 const years = [...new Set(galleryState.albums.map(a => a.year).filter(Boolean))].sort().reverse(); 547 const title = galleryState.trash ? 'åæ¶ç«' : album?.title || 'å®éªå®¤ç¸å'; 548 let controls = tool('refresh-gallery', 'å·æ°ç¸å', 'refresh-cw'); 549 if (admin) controls += action('new-album', 'æ°å»ºç¸å', 'folder-plus') + action('upload-photos', 'ä¸ä¼ ç §ç', 'upload', 'primary-button') + tool('gallery-trash', galleryState.trash ? 'è¿åç¸å' : 'åæ¶ç«', galleryState.trash ? 'images' : 'archive-restore') + tool('logout', 'éåºç®¡ç', 'log-out'); 550 else controls += action('manage-gallery', 'ç¸å管ç', 'lock-keyhole'); 551 const header = pageHead(title, controls, album?.description || 'æ¯ä¸åå½±ãå®éªå®¤æ¥å¸¸ä¸å ±åç»åã'); 552 const sub = `<div class="gallery-breadcrumb"><a href="#/gallery">å ¨é¨ç¸å</a>${album ? `<span>/ ${esc(album.title)}</span>` : ''}${admin && album && album.id !== 'legacy' ? action('edit-album', 'ç¼è¾ç¸å', 'pencil', 'text-button', `data-id="${esc(album.id)}"`) : ''}</div>`; 553 const filters = `<div class="filter-bar"><label>æç´¢${state.albumId || galleryState.trash ? 'ç §ç' : 'ç¸å'}<input id="galleryQuery" type="search" value="${esc(state.galleryQuery || '')}" placeholder="æ é¢æè¯´æ"></label>${!state.albumId && !galleryState.trash ? `<label>æ¯ä¸å±å«<select id="galleryYear"><option value="">å ¨é¨å±å«</option>${years.map(year => `<option value="${esc(year)}" ${state.galleryYear === year ? 'selected' : ''}>${esc(year)}å±</option>`).join('')}</select></label>` : ''}<span>${state.albumId || galleryState.trash ? photos.length + 'å¼ ç §ç' : albums.length + '个ç¸å'}</span></div>`; 554 if (galleryState.error) return header + `<div class="online-error" role="alert">${esc(galleryState.error)}${action('refresh-gallery', 'éè¯', 'rotate-cw')}</div>`; 555 if (!galleryState.loaded) return header + empty('æ£å¨å è½½ç¸åâ¦'); 556 if (state.albumId && !album && !galleryState.trash) return header + sub + empty('è¿ä¸ªç¸åä¸å卿已å é¤'); 557 if (!state.albumId && !galleryState.trash) return header + filters + `<div class="album-grid">${albums.map(albumCard).join('')}</div>` + (!albums.length ? empty(query || state.galleryYear ? '没æç¬¦åæ¡ä»¶çç¸å' : 'ç¸åå°æªåå¸') : ''); 558 const guestbookLink = !galleryState.trash && album ? `<section class="album-guestbook-entry"><div><h2>ç §ççä½ç¸èï¼çè¨æ¥ç说ã</h2><p>${album.year ? esc(album.year)+' å±çè¿åµä¸ç¥ç¦' : 'ç»å¯¼å¸çä¸å¥æè°¢ï¼ææ¯ä¸åçè¿åµã'}</p></div><a href="#/guestbook${album.year ? '?year='+encodeURIComponent(album.year) : ''}">${album.year ? 'ççè¿ä¸å±ççè¨' : 'åå¾çè¨å¢'} ${icon('arrow-up-right')}</a></section>` : ''; 559 return header + sub + filters + `<div class="photo-grid">${photos.slice(0, galleryState.limit).map(photo => `<article class="photo-tile"><button class="photo-open" data-do="view-photo" data-id="${esc(photo.id)}"><img src="${esc(photo.thumbnailUrl)}" alt="${esc(photo.title)}" loading="lazy" width="${photo.width || 800}" height="${photo.height || 600}"></button><div class="photo-caption"><div><strong>${esc(photo.title)}</strong><small>${esc([photo.dateConfirmed && photo.date ? 'ææäº ' + photo.date : '', photo.uploader].filter(
559Boolean).join(' · '))}</small></div>${admin ? galleryState.trash ? tool('restore-photo', 'æ¢å¤ç §ç', 'archive-restore', `data-file="${esc(photo.filename)}"`) : tool('edit-photo', 'ç¼è¾ç §ç', 'pencil', `data-id="${esc(photo.id)}"`) + tool('trash-photo', 'ç§»å ¥åæ¶ç«', 'trash-2', `data-file="${esc(photo.filename)}"`) : ''}</div></article>`).join('')}</div>` + (!photos.length ? empty(galleryState.trash ? 'åæ¶ç«ä¸ºç©º' : 'æ¤ç¸åå°æ ç §ç') : '') + (photos.length > galleryState.limit ? action('more-photos', 'æ¾ç¤ºæ´å¤ç §ç', 'chevron-down') : '') + guestbookLink; 560 }; 561 renderShared = function () { 562 const shared = papers.filter(p => p.scope === 'shared'); 563 return pageHead('å ±å空é´', '', 'å ±åæç®ä¿åå¨å½åæµè§å¨ï¼å®éªå®¤ç¸åé¢åææè®¿é®è ã') + `<section class="section-block"><div class="section-title"><h2>å ±åæç®</h2>${action('shared-papers', 'æ¥çæç®', 'files')}</div>${shared.slice(0, 4).map(p => `<button class="record-row" data-paper="${esc(p.id)}"><span><strong>${esc(p.title)}</strong><small>${esc(p.meta || '')}</small></span>${icon('chevron-right')}</button>`).join('') || empty('è¿æ²¡æå ±åæç®')}</section><section class="section-block"><div class="section-title"><h2>å®éªå®¤ç¸å</h2><a class="text-button" href="#/gallery">å ¨é¨ç¸å â</a></div><div class="album-grid">${galleryState.albums.slice(0, 3).map(albumCard).join('')}</div>${galleryState.error ? `<p role="alert">${esc(galleryState.error)}</p>` : !galleryState.albums.length ? empty('ç¸åå°æªåå¸') : ''}</section>`; 564 }; 565 function albumForm(id = '') { 566 const album = galleryState.albums.find(a => a.id === id) || {}; 567 showModal(id ? 'ç¼è¾ç¸å' : 'æ°å»ºç¸å', `<form id="albumForm" data-id="${esc(id)}" class="paper-form"><label>ç¸ååç§°<input name="title" required maxlength="120" value="${esc(album.title || '')}
567" placeholder="ä¾å¦ï¼2026屿¯ä¸åå½±"></label><div class="form-row"><label>æ¯ä¸å±å«ï¼å¯éï¼<input name="year" type="number" min="1900" max="2200" value="${esc(album.year || '')}"></label><label>æ´»å¨ç±»å<select name="category">${['æ¯ä¸åå½±', 'å®éªå®¤æ¥å¸¸', '妿¯æ´»å¨', 'å¢å»ºæ´»å¨', 'å ¶ä»'].map(c => `<option ${c === album.category ? 'selected' : ''}>${c}</option>`).join('')}</select></label></div><label>ç¸å说æ<textarea name="description" rows="3" maxlength="1000">${esc(album.description || '')}</textarea></label>${modalActions()}${id ? action('delete-album', 'å é¤ç©ºç¸å', 'trash-2', 'text-button danger', `data-id="${esc(id)}"`) : ''}</form>`); 568 } 569 function albumOptions(selected = '') { 570 return galleryState.albums.map(a => `<option value="${esc(a.id)}" ${a.id === selected ? 'selected' : ''}>${esc(a.title)}</option>`).join(''); 571 } 572 function photoForm(id = '') { 573 const photo = galleryState.items.find(p => p.id === id); 574 if (!photo && !galleryState.albums.some(a => a.id !== 'legacy')) return albumForm(); 575 showModal(photo ? 'ç¼è¾ç §ç' : 'ä¸ä¼ ç §ç', `<form id="${photo ? 'photoEditForm' : 'photoUploadForm'}" data-file="${esc(photo?.filename || '')}" class="paper-form"><label>ç¸å<select name="albumId" required>${albumOptions(photo?.albumId || state.albumId)}</select></label>${photo ? '' : '<label>ç §çï¼JPGãPNGãWebPï¼åå¼ æå¤§8MBï¼<input name="files" type="file" accept="image/jpeg,image/png,image/webp" multiple required></label>'}<label>æ é¢${photo ? '' : 'ï¼ç空æ¶ä½¿ç¨æä»¶åï¼'}<input name="title" maxlength="120" value="${esc(photo?.title || '')}" ${photo ? 'required' : ''}></label><div class="form-row"><label>æææ¥æï¼éå¡«ï¼æªç¥å¯ç空ï¼<input name="date" type="date" value="${esc(photo?.date || '')}"></label><label>æå½±æä¸ä¼ è <input name="uploader" maxlength="60" value="${esc(photo?.uploader || '')}"></label></div><label>说æ<textarea name="description" rows="3" maxlength="1000">${esc(photo?.description || '')}</textarea></label>${photo && photo.date && !photo.dateConfirmed ? '<p class="form-help">æ§è®°å½æ¥æå°æªç¡®è®¤ï¼è¯·æ ¸å¯¹æææ¥æåä¿åï¼ä¸ç¡®å®å¯æ¸ 空ã</p>' : ''}${photo ? '' : '<p class="form-help">ç §çä¼å ¬å¼å±ç¤ºãç³»ç»çææµè§å¯æ¬å¹¶ç§»é¤å®ä½çEXIFä¿¡æ¯ï¼è¯·èªè¡ä¿çåå§æä»¶ã</p>'}<p id="uploadStatus" role="status"></p>${modalActions(photo ? 'ä¿å' : 'ä¸ä¼ ')}</form>`, true); 576 if (!photo) modalRoot.querySelector('option[value="legacy"]')?.remove(); 577 } 578 async function viewPhoto(id) { 579 const items = galleryPhotos(); 580 const index = items.findIndex(p => p.id === id); if (index < 0) return; 581 const { default: PhotoSwipe } = await import('./assets/vendor/photoswipe.esm.min.js'); 582 const photos = await Promise.all(items.map(async p => { 583 let width = p.width, height = p.height; 584 if (!width || !height) { 585 const dimensions = await new Promise(resolve => { const image = new Image(); image.onload = () => resolve([image.naturalWidth, image.naturalHeight]); image.onerror = () => resolve([1200, 800]); image.src = p.url; }); 586 [width, height] = dimensions; 587 } 588 return { src: p.url, msrc: p.thumbnailUrl, width, height, alt: p.title, title: p.title, description: [p.description, photoDateLabel(p), p.uploader].filter(Boolean).join(' · ') }; 589 })); 590 photoViewer?.destroy(); 591 photoViewer = new PhotoSwipe({ dataSource: photos, index, bgOpacity: 0.96, closeTitle: 'å ³é', zoomTitle: '缩æ¾', arrowPrevTitle: 'ä¸ä¸å¼ ', arrowNextTitle: 'ä¸ä¸å¼ ', errorMsg: 'ç §çå 载失败ï¼è¯·å ³éåéè¯ã' }); 592 photoViewer.on('uiRegister', () => { 593 photoViewer.ui.registerElement({ name: 'caption', order: 9, isButton: false, appendTo: 'root', onInit: (element, viewer) => { 594 viewer.on('change', () => { const item = viewer.currSlide.data; element.textContent = [item.title, item.description].filter(Boolean).join(' · '); }); 595 } }); 596 photoViewer.ui.registerElement({ name: 'download', order: 8, isButton: fal
596se, tagName: 'a', html: 'ä¸è½½', onInit: (element, viewer) => { 597 element.setAttribute('aria-label', 'ä¸è½½ç §ç'); element.setAttribute('download', ''); 598 viewer.on('change', () => { element.href = viewer.currSlide.data.src; }); 599 } }); 600 }); 601 photoViewer.init(); 602 } 603 604 function renderSettings() { 605 let bytes = 0; 606 for (const key of Object.keys(localStorage)) if (key.startsWith('zhixing-')) bytes += (localStorage.getItem(key) || '').length * 2; 607 return pageHead('è®¾ç½®ä¸æ°æ®') + `<section class="settings-section"><h2>æ¬æºèµæ</h2><p>æç®ãæç®åç»åä¸å©å¨æ¤æµè§å¨å ±ç¨ï¼å¥åº·è®°å½åç¬è®°æèå¸ç©ºé´åå«ä¿åãæ¢è®¾å¤ä¸ä¼èªå¨åæ¥ï¼åæ¢ç©ºé´ä¹ä¸å±äºèº«ä»½éªè¯ã</p><dl><dt>æ¬æºæ°æ®å¤§å°ï¼ä¼°ç®ï¼</dt><dd>${(bytes / 1024 / 1024).toFixed(2)} MB</dd><dt>åå²å¥åº·è®°å½</dt><dd>ä¿çå ¨é¨è®°å½ï¼é»è®¤æ¥çæè¿31天</dd></dl><div class="button-row">${action('export-data', 'å¯¼åºæ¬æºå¤ä»½', 'download', 'primary-button')}${action('import-data', 'åå¹¶å¯¼å ¥å¤ä»½', 'upload')}</div><p class="form-help">å¤ä»½å 嫿¬æºç ç©¶èµæåå¥åº·è®°å½ï¼è¯·èªè¡å¦¥åä¿ç®¡ãPDF页é¢å¾çä¸å å«å¨å¤ä»½ä¸ï¼åå§PDF请å¦è¡ä¿åã</p></section><section class="settings-section"><h2>å®éªå®¤ç¸å</h2><p>ç¸åå ¬å¼æµè§ï¼ä¸éè¦æ¯ä¸çè´¦å·ãç §çä¿å卿å¡å¨æä¹ ç®å½ï¼ç®¡çæä½éè¦åç¬éªè¯ã</p><div class="button-row"><a class="secondary-button" href="#/gallery">æå¼ç¸å</a>${action('gallery-backup', '导åºç¸åç®å½', 'download')}</div><p class="form-help">ç¸åç®å½å¤ä»½ä¸å«ç §çæä»¶ï¼å®æ´å¤ä»½è¿éä¿åBucketä¸çç §çå缩ç¥å¾ã</p></section><section class="settings-section"><h2>çè¨å¤ä»½</h2><p>管çåå¯å¯¼åºå ¨é¨çè¨ã导å¸åå¤ãç¹èµååæ¶ç«å å®¹ãæ¬æºå¤ä»½ä¸å å«è¿äºå ±äº«çè¨ã</p><div class="button-row"><a class="secondary-button" href="#/guestbook">æå¼çè¨å¢</a>${action('guestbook-admin', 'çè¨ç®¡çä¸å¤ä»½', 'download')}</div></section><section class="settings-section"><h2>æå¡è¿æ¥</h2>${action('check-service', 'æ£æ¥è¿æ¥', 'activity')}<p id="serviceStatus" role="status"></p></section>`; 608 } 609 function renderHelp() { 610 return pageHead('使ç¨è¯´æ') + `<div class="help-content"><section><h2>æç®ä¸ç¬è®°</h2><p>å¨çº¿æ¥è¯¢åéæ©âä¿åå¹¶é 读âãPDFå¯å¨æç®è¯¦æ ä¸ä¼ ï¼ä¹å¯ä»¥è·åæ¥æºæä¾ç弿¾PDFãéå°åºç社ææé¡µé¢æ¶ï¼è¯·éè¿åæç½ç«è·åæä»¶ãç¬è®°æ¯æç¼è¾ãæç´¢åæ¶èã</p></section><section><h2>å¥åº·è®°å½</h2><p>æç±»åå¡«åæ°å¼åæµéæ¥æãæ²¡æå½å ¥çæ°æ®ä¼æ¾ç¤ºâæªè®°å½âï¼åå²è®°å½å¯åæ¢âå ¨é¨åå²âæ¥çãç¨è¯ç¡®è®¤æå½å¤©è®°å½ã</p></section><section><h2>å®éªå®¤ç¸å</h2><p>æ¯ä¸çå¯ç´æ¥æµè§ç¸åã大å¾åä¸è½½ç §çãèå¸è¿å ¥âç¸å管çâåå¯å建ç¸åãæ¹éä¸ä¼ ãç¼è¾ææ¢å¤è¯¯å ç §çãç½å被转ååï¼å ¶ä»äººä¹è½æ¥çå ¬å¼ç §çã</p></section><section><h2>çè¨å¢</h2><p>æå¼æ¬äººå¨å½åæµè§å¨åå¸ççè¨ï¼å¯ä¿®æ¹éåæå é¤ãå é¤åéè¦ç¡®è®¤ï¼æ§çè¨ãæ¢è®¾å¤ææ¸ é¤ Cookie åççè¨è¯·è系管çåå¤çãâçè¨ç®¡çâ使ç¨ç¸å管çå£ä»¤ï¼ç®¡çåå¯åå¤ãå é¤ï¼å¹¶ä»åæ¶ç«æ¢å¤åæãåå¤åç¹èµã</p></section><section><h2>æ¾ç¤ºè®¾ç½®</h2><p>é¡¶é¨âæ¾ç¤ºè®¾ç½®âå¯åæ¢é»è®¤ææ¤ç¼é è²ï¼å¹¶éæ©æ åãè¾å¤§æå¤§åå·ãâè±åå¨ç»âå¯è®¾ä¸ºéæï¼åæ¢è±æåå¡çæå¨ã设置ç«å³çæï¼å¨å½åæµè§å¨è®°ä½éæ©ï¼ç §çä¿æåè²ï¼PDFå¾ç请使ç¨é 读å¨ç¼©æ¾ãç¹å»âæ¢å¤é»è®¤âå¯è¿åã</p></section><section><h2>ä¿åä¸å¤ä»½</h2><p>ä¸ªäººèµæä¿åå¨å½åæµè§å¨ï¼æ¸ çç½ç«æ°æ®å¯è½å¯¼è´ä¸¢å¤±ã请å¨è®¾ç½®ä¸å®æå¯¼åºå¤ä»½ï¼å¯¼å ¥éç¨åå¹¶æ¹å¼ãç¸ååçè¨æ¯æå¡å¨ä¸çå ±äº«æ°æ®ï¼ä¸å±äºæ¬æºå¤ä»½ã管çåå¯å¨âçè¨ç®¡çâæè®¾ç½®é¡µè¿å ¥ç®¡çå导åºçè¨å¤ä»½ï¼å å«åæ¶ç«å 容ï¼ç¸å宿´å¤ä»½è¿éä¿åç §çæä»¶ã</p></section></div>`; 611 }
612 function renderSearch() { 613 const query = (state.searchQuery || '').trim().toLowerCase(); 614 const groups = [ 615 ['æç®', papers.filter(p => state.currentSpace !== 'shared' || p.scope === 'shared').map(p => ({ text: p.title || p.zh, detail: p.meta, match: `${p.title} ${p.zh} ${p.doi} ${p.tag}`, attrs: `data-paper="${esc(p.id)}"` }))], 616 ['ç¬è®°', notes().map(n => ({ text: n.title, detail: n.tag, match: `${n.title} ${n.content} ${n.tag}`, attrs: `data-do="edit-note" data-id="${esc(n.id)}"` }))], 617 ['æç®åç»', projectGroups.map(g => ({ text: g.title, detail: g.desc, match: `${g.title} ${g.desc}`, attrs: `data-open-project="${esc(g.id)}"` }))], 618 ['ä¸å©', patentLibrary.map((p, i) => ({ text: p.title, detail: p.number, match: `${p.title} ${p.number} ${p.abstract}`, attrs: `data-library-patent="${i}"` }))], 619 ['ç¾é£', Object.entries(cityData).flatMap(([city, data]) => (data.restaurants || []).map(r => ({ text: r[0], detail: city, match: `${city} ${r.join(' ')}`, attrs: `data-do="search-food" data-city="${esc(city)}"` })))], 620 ]; 621 return pageHead('æç´¢', '', query ? `â${state.searchQuery}â` : '请è¾å ¥å ³é®è¯') + (query ? groups.map(([label, rows]) => { 622 const matches = rows.filter(r => r.match.toLowerCase().includes(query)); 623 return `<section class="search-section"><h2>${label}<small>${matches.length}</small></h2>${matches.map(r => `<button class="record-row" ${r.attrs}><span><strong>${esc(r.text)}</strong><small>${esc(r.detail || '')}</small></span>${icon('chevron-right')}</button>`).join('') || empty('没æå¹é ç»æ')}</section>`; 624 }).join('') : ''); 625 } 626 627 function downloadJson(filename, data) { 628 const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); 629 const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); 630 setTimeout(() => URL.revokeObjectURL(url), 1000); 631 } 632 function exportData() { 633 const data = {}; 634 for (const key of Object.keys(localStorage)) if (key.startsWith('zhixing-') && key !== 'zhixing-last-route') data[key] = read(key, null); 635 downloadJson(`ç¥è¡å®éªå®¤-æ¬æºå¤ä»½-${localDate()}.json`, { format: 'zhixing-local', version: 1, exportedAt: new Date().toISOString(), data }); 636 } 637 function importDialog() { 638 showModal('åå¹¶å¯¼å ¥å¤ä»½', `<form id="backupForm" class="paper-form"><p>å¯¼å ¥ä¼è¡¥å 缺å°çè®°å½ï¼ç¸åIDä¿çæ¬æºçæ¬ãå¥åº·åå²æè®°å½åå¹¶ï¼ä¸å é¤ç°æèµæã</p><label>æ¬æºå¤ä»½æä»¶<input name="file" type="file" accept="application/json,.json" required></label>${modalActions('åå¹¶å¯¼å ¥')}</form>`); 639 } 640 function mergeRows(current, incoming) { 641 const rows = Array.isArray(current) ? current.slice() : []; 642 const key = row => row && typeof row === 'object' ? row.id || JSON.stringify(row) : JSON.stringify(row); 643 const seen = new Set(rows.map(key)); 644 for (const item of incoming) if (!seen.has(key(item))) { rows.push(item); seen.add(key(item)); } 645 return rows; 646 } 647 async function importBackup(file) { 648 if (!file || file.size > 25 * 1024 * 1024) throw new Error('è¯·éæ©25MB以å çå¤ä»½æä»¶'); 649 const backup = JSON.parse(await file.text()); 650 if (backup.format !== 'zhixing-local' || backup.version !== 1 || !backup.data || typeof backup.data !== 'object') throw new Error('䏿¯ææçç¥è¡å®éªå®¤å¤ä»½'); 651 const allowed = /^(zhixing-(papers|project-groups|patents|patent-library|food-wheel|notes-(zhang|duan|shared)|health-(zhang|duan|shared|landing)))$/; 652 const updates = []; 653 for (const [key, incoming] of Object.entries(backup.data)) { 654 if (!allowed.test(key)) continue; 655 if (!incoming || typeof incoming !== 'object') throw new Error('å¤ä»½æ°æ®æ ¼å¼é误'); 656 const current = read(key, null); 657 let next; 658 if (Array.isArray(incoming)) { 659 if (!incoming.every(row => row && typeof row === 'object' && !Array.isArray(row))) throw new Error('å¤ä»½è®°å½æ ¼å¼é误'); 660 if (key.includes('notes-') && !incoming.every(r => typeof r.id === 'string' && typeof r.title === 'string' && typeof r.content === 'string' && typeof r.updatedAt === 'string')) throw new Error('å¤ä»½ç¬è®°å段ä¸å®æ´'); 661 if (key === 'zhixing-project-groups' && !incoming.every(r => typeof r.id === 'string' && typeof r.title === 'string')) throw new Error('å¤ä»½åç»å段ä¸å®æ´'); 662 next = mergeRows(current, incoming); 663 } else if (key.includes('health-')) { 664 if (!Array.isArray(incoming.timeline) || !Array.isArray(incoming.medications)) throw new Error('å¥åº·å¤ä»½æ ¼å¼é误'); 665 next = { ...incoming, ...(current || {}), timeline: mergeRows(current?.timeline, incoming.timeline), medications: mergeRows(current?.medications, incoming.medications) }; 666 } else if (key === 'zhixing-food-wheel') next = { ...incoming, ...(current || {}) }; 667 else throw new Error('å¤ä»½æ°æ®ç±»åé误'); 668 updates.push([key, next, localStorage.getItem(key)]); 669 } 670 try { for (const [key, value] of updates) write(key, value); } 671 catch (error) { 672 for (const [key, , previous] of updates) if (previous === null) localStorage.removeItem(key); else localStorage.setItem(key, previous); 673 throw error; 674 } 675 location.reload(); 676 } 677 async function runAction(name, el) {
678 const id = el?.dataset.id, file = el?.dataset.file, index = Number(el?.dataset.index); 679 switch (name) { 680 case 'display-settings': return displaySettings(); 681 case 'reset-display': { const saved = SiteDisplay.set({ theme:'default', size:'standard' }); syncDisplayControls(); document.querySelector('#readingStatus').textContent = saved ? 'å·²æ¢å¤é»è®¤è®¾ç½®ã' : 'å·²æ¢å¤é»è®¤ï¼æµè§å¨åå¨ä¸å¯ç¨ã'; return; } 682 case 'mentor-spaces': return mentorSpaces(); 683 case 'browse-home-albums': document.querySelector('#homeAlbums')?.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' }); document.querySelector('#homeAlbums')?.focus({ preventScroll: true }); return; 684 case 'home-year': homeYear = el.dataset.year; return refreshHomeCatalog(); 685 case 'home-category': homeCategory = el.dataset.category; return refreshHomeCatalog(); 686 case 'reset-home-filters': homeYear = ''; homeCategory = ''; homeQuery = ''; document.querySelector('#homeAlbumQuery').value = ''; document.querySelector('#homeAlbumYear').value = ''; return refreshHomeCatalog(); 687 case 'film-motion': return document.querySelector('.filmstrip')?.toggleMotion(); 688 case 'new-guestbook': return newGuestbook(); 689 case 'refresh-guestbook': return loadGuestbook(); 690 case 'open-guestbook': return openGuestbook(id); 691 case 'guestbook-year': guestbookYear = el.dataset.year; state.guestbookYearRoute = guestbookYear || 'all'; guestbookPage = 0; history.replaceState(null, '', routeHash()); renderView(); return; 692 case 'guestbook-admin': { 693 const session = await api('/api/gallery/session', undefined, 'GET'); 694 Object.assign(galleryState, session); 695 if (session.admin) { if (id) return openGuestbook(id); return guestbookManagement(); } 696 if (!session.managementEnabled) return showModal('çè¨ç®¡ç', '<p>çè¨ç®¡çå°æªå¯ç¨ï¼è¯·èç³»ç½ç«ç»´æ¤è 设置管çå£ä»¤ã</p>'); 697 return showModal('çè¨ç®¡çç»å½', `<form id="managerForm" data-guestbook="true" data-id="${esc(id || '')}" class="paper-form"><p class="form-help">使ç¨ç½ç«ç®¡çå£ä»¤ç»å½ï¼å¯åå¤ãå é¤çè¨ï¼å¹¶æ¢å¤åæ¶ç«ä¸ççè¨ã</p><label>管çå£ä»¤<input type="password" name="password" required autocomplete="current-password"></label>${modalActions('è¿å ¥çè¨ç®¡ç')}</form>`); 698 } 699 case 'edit-guestbook': return newGuestbook(id); 700 case 'delete-guestbook': return deleteGuestbook(id); 701 case 'guestbook-trash': return guestbookTrash(); 702 case 'restore-guestbook': { 703 if (el.disabled) return; 704 el.disabled = true; 705 try { await api('/api/gallery/guestbook/restore/' + encodeURIComponent(id), {}); await loadGuestbook(); await guestbookTrash(); toast('çè¨å·²æ¢å¤'); } 706 catch (error) { el.disabled = false; throw error; } 707 return; 708 } 709 case 'reply-guestbook': return replyGuestbook(id); 710 case 'guestbook-logout': await api('/api/gallery/logout', {}); galleryState.admin = false; closePaperModal(); return renderView(); 711 case 'guestbook-page': guestbookPage = Number(el.dataset.page); renderView(); main.querySelector('.garden-toolbar')?.scrollIntoView({block:'start'}); return; 712 case 'like-guestbook': { 713 const row = guestbookItems.find(item => item.id === id); 714 if (row?.liked || el.disabled) return; 715 el.disabled = true; 716 try { 717 const data = await api('/api/guestbook/like/'+encodeURIComponent(id),{}); 718 if (row) Object.assign(row, data.item, { liked: true }); 719 el.innerHTML = `⥠<span data-like-count>${Number(row?.likes)||0}</span> · å·²éåºå¿æ`; 720 renderView(); 721 } catch (error) { el.disabled = false; throw error; } 722 return; 723 } 724 case 'film-prev': return document.querySelector('.filmstrip')?.movePhoto(-1); 725 case 'film-next': return document.querySelector('.filmstrip')?.movePhoto(1); 726 case 'close': return closePaperModal(); 727 case 'online': return openOnlineModal(); 728 case 'retry-search': return searchOnlinePapers(state.onlineQuery, state.onlineSource, state.onlineRange); 729 case 'more-online': return searchOnlinePapers(state.onlineQuery, state.onlineSource, state.onlineRange, true); 730 case 'cancel-search': return searchController?.abort(); 731 case 'save-online': return saveOnlinePaper(index); 732 case 'open-online': return goExternal(state.onlineResults[index]?.url); 733 case 'add-paper': return addPaper(); 734 case 'paper-abstract': state.readerTab = 'original'; return renderView(); 735 case 'paper-pdf': state.readerTab = 'pdf'; return renderView(); 736 case 'upload-pdf': return uploadPdf(); 737 case 'open-paper': return goExternal(getSelectedPaper()?.url); 738 case 'fetch-pdf': { 739 el.disabled = true; el.textContent = 'æ£å¨è·åâ¦'; 740 const paper = getSelectedPaper(); 741 try { const data = await api('/api/fetch-pdf', { url: paper.pdfUrl }); Object.assign(paper, data, { pdfVerified: true, pdfError: '' }); savePapers(); } 742 catch (error) { paper.pdfError = error.message; } 743 state.readerTab = 'pdf'; return renderView(); 744 } 745 case 'share-paper': { const p = getSelectedPaper(); p.scope = 'shared'; savePapers(); toast('å·²å å ¥æ¬æºå ±åæç®'); return renderView(); } 746 case 'delete-paper': if (confirm('å é¤è¿ç¯æç®çæ¬æºè®°å½ï¼')) { papers = papers.filter(p => p.id !== id); savePapers(); state.selectedPaper = ''; renderView(); } return; 747 case 'all-papers': state.projectGroup = ''; state.paperQuery = ''; return navigate('papers'); 748 case 'shared-papers': state.projectGroup = ''; return navigate('papers', 'shared'); 749 case 'new-note': return editNote(); 750 case 'edit-note': return editNote(id); 751 case 'favorite-note': { const list = notes(); const note = list.find(n => n.id === id); note.favorite = !note.favorite; write(noteKey(), list); return renderView(); } 752 case 'delete-note': if (confirm('å é¤è¿ç¯ç¬è®°ï¼')) { write(noteKey(), notes().filter(n => n.id !== id)); renderView(); } return; 753 case 'reset-groups': if (confirm('å°åç»åç§°åè¯´ææ¢å¤ä¸ºé»è®¤å¼ï¼')) { projectGroups = defaultProjectGroups.map(g => ({ ...g })); saveProjectGroups(); renderView(); } return; 754 case 'health-record': return editHealth(); 755 case 'edit-health': return editHealth(id); 756 case 'delete-health': if (confirm('å é¤è¿æ¡å¥åº·è®°å½ï¼')) { const h = getHealth(); h.timeline.splice(index, 1); saveHealth(h); renderView(); } return; 757 case 'medication': return showModal('æ·»å ç¨è¯', `<form id="medicationForm" class="paper-form"><label>åç§°<input name="name" required maxlength="120"></label><label>æ¶é´<input name="time" type="time" required></label><label>夿³¨<input name="note" maxlength="500"></label>${modalActions()}</form>`); 758 case 'confirm-medication': { 759 const h = getHealth(), med = h.medications[index]; 760 const dates = med.doneDates || []; med.doneDates = dates.includes(localDate()) ? dates.filter(d => d !== localDate()) : [...dates, localDate()]; 761 saveHealth(h); return renderView(); 762 } 763 case 'delete-medication': if (confirm('å é¤è¿é¡¹ç¨è¯è®¡åï¼')) { const h = getHealth(); h.medications.splice(index, 1); saveHealth(h); renderView(); } return; 764 case 'refresh-gallery': return loadGallery(); 765 case 'manage-gallery': 766 if (!galleryState.managementEnabled) return showModal('ç¸å管ç', '<p>æµè§ç §çæ éå£ä»¤ãç½ç«å 管çå°æªå¯ç¨ï¼è¯·ç±é¨ç½²è å¨ Hugging Face Secrets ä¸é ç½® GALLERY_ADMIN_PASSWORD åé坿å¡ã</p>'); 767 return showModal('ç¸å管ç', `<form id="managerForm" class="paper-form"><label>管çå£ä»¤<input type="password" name="password" required autocomplete="current-password"></label>${modalActions('è¿å ¥ç®¡ç')}</form>`); 768 case 'logout': await api('/api/gallery/logout', {}); galleryState.admin = false; galleryState.trash = false; return loadGallery(); 769 case 'new-album': return albumForm(); 770 case 'edit-album': return albumForm(id); 771 case 'delete-album': if (confirm('å é¤è¿ä¸ªç©ºç¸åï¼')) { await api('/api/gallery/album/' + encodeURIComponent(id), undefined, 'DELETE'); closePaperModal(); state.albumId = ''; await loadGallery(); navigate('gallery'); } return; 772 case 'upload-photos': return photoForm(); 773 case 'edit-photo': return photoForm(id); 774 case 'view-photo': return viewPhoto(id); 775 case 'trash-photo': if (confirm('å°ç §çç§»å ¥åæ¶ç«ï¼ä¹åå¯ä»¥æ¢å¤ã')) { await api('/api/gallery/photo/' + encodeURIComponent(file), undefined, 'DELETE'); await loadGallery(); } return; 776 case 'restore-photo': await api('/api/gallery/restore', { filename: file }); return loadGallery(); 777 case 'gallery-trash': galleryState.trash = !galleryState.trash; return loadGallery(); 778 case 'more-photos': galleryState.limit += 60; return renderView(); 779 case 'export-data': return exportData(); 780 case 'import-data': return importDialog(); 781 case 'guestbook-backup': { 782 const data = await api('/api/gallery/guestbook/backup', undefined, 'GET');
783 return downloadJson(`ç¥è¡å®éªå®¤-çè¨å¤ä»½-${localDate()}.json`, data); 784 } 785 case 'gallery-backup': { 786 const data = await api('/api/gallery/backup', undefined, 'GET'); 787 return downloadJson(`å®éªå®¤ç¸åç®å½-${localDate()}.json`, data); 788 } 789 case 'check-service': { 790 el.disabled = true; 791 try { const data = await api('/api/health', undefined, 'GET'); document.querySelector('#serviceStatus').textContent = data.ok ? 'ç«ç¹æå¡è¿æ¥æ£å¸¸' : 'æå¡ç¶æå¼å¸¸'; } 792 catch (error) { document.querySelector('#serviceStatus').textContent = error.message; } 793 finally { el.disabled = false; } return; 794 } 795 case 'search-food': state.currentCity = el.dataset.city; return navigate('food'); 796 case 'mobile-search': showModal('æç´¢', `<form id="mobileSearchForm" class="paper-form"><label>å ³é®è¯<input name="query" type="search" required></label>${modalActions('æç´¢')}</form>`); return; 797 } 798 } 799 function showError(error) { 800 const node = document.querySelector('#modalError'); 801 if (node) node.textContent = error.message || 'æä½å¤±è´¥ï¼è¯·éè¯'; else toast(error.message || 'æä½å¤±è´¥ï¼è¯·éè¯'); 802 } 803 804 document.addEventListener('click', event => { 805 const el = event.target.closest('button,a,[data-close-modal]'); if (!el) return; 806 if (el.hasAttribute('data-close-modal') && event.target === el) { event.preventDefault(); event.stopImmediatePropagation(); closePaperModal(); return; } 807 const routeLink = el.tagName === 'A' && el.getAttribute('href')?.startsWith('#/'); 808 const captured = routeLink || el.dataset.do || el.dataset.view || el.dataset.space || el.dataset.paper || el.dataset.openProject || el.hasAttribute('data-library-patent') || el.hasAttribute('data-delete-patent') || el.id === 'sidebarToggle'; 809 if (captured) { 810 event.preventDefault(); event.stopImmediatePropagation(); 811 try { 812 if (routeLink && el.classList.contains('film-frame') && !el.closest('.filmstrip').shouldOpenPhoto(el)) return; 813 if (routeLink) { const route = LabCore.route(el.getAttribute('href')); state.guestbookYearRoute = route.guestbookYearRoute || ''; Object.assign(state, route); if (state.view === 'guestbook') { guestbookYear = state.guestbookYearRoute === 'all' ? '' : state.guestbookYearRoute || null; guestbookPage = 0; if (guestbookYear === null && guestbookLoaded) guestbookYear = guestbookItems.map(row=>row.year).filter(Boolean).sort().reverse()[0] || ''; } state.galleryQuery = ''; galleryState.trash = false; return navigate(state.view, state.currentSpace); } 814 if (el.id === 'sidebarToggle') { 815 if (matchMedia('(max-width:900px)').matches) setMobileSidebar(!document.body.classList.contains('mobile-sidebar-open')); 816 else document.querySelector('#app').classList.toggle('sidebar-collapsed'); return; 817 } 818 if (el.dataset.view) return setView(el.dataset.view); 819 if (el.dataset.space) return setSpace(el.dataset.space); 820 if (el.dataset.paper) return selectPaper(el.dataset.paper); 821 if (el.dataset.openProject) return openProjectGroup(el.dataset.openProject); 822 if (el.hasAttribute('data-library-patent')) return openPatentReader(Number(el.dataset.libraryPatent), true); 823 if (el.hasAttribute('data-delete-patent')) { if (confirm('å é¤è¿é¡¹æ¬æºä¸å©è®°å½ï¼')) deletePatent(el.dataset.deletePatent); return; } 824 Promise.resolve(runAction(el.dataset.do, el)).catch(showError); 825 } catch (error) { showError(error); } 826 return; 827 } 828 const legacy = el.dataset.action; 829 const handlers = { 'add-patent': openPatentAddModal, 'spin-food': spinFoodWheel, 'add-wheel': addFoodWheelItem, 'online-search': openOnlineModal, 'add-paper': addPaper }; 830 if (handlers[legacy]) { event.preventDefault(); event.stopImmediatePropagation(); try { handlers[legacy](); } catch (error) { showError(error); } } 831 if (el.hasAttribute('data-remove-wheel')) { event.stopImmediatePropagation(); removeFoodWheelItem(Number(el.dataset.removeWheel)); } 832 if (el.hasAttribute('data-delivery-keyword')) { event.stopImmediatePropagation(); goExternal('https://www.baidu.com/s?wd=' + encodeURIComponent(state.currentCity + ' ' + el.dataset.deliveryKeyword + ' å¤å')); } 833 }, true); 834 835 document.addEventListener('submit', async event => { 836 const form = event.target; 837 const forms = ['noteForm', 'healthRecordForm', 'medicationForm', 'literatureForm', 'newPaperForm', 'attachPdfForm', 'managerForm', 'albumForm', 'photoEditForm', 'photoUploadForm', 'backupForm', 'mobileSearchForm', 'guestbookForm', 'guestbookReplyForm', 'guestbookDeleteForm']; 838 if (!forms.includes(form.id)) return; 839 event.preventDefault(); event.stopImmediatePropagation(); 840 if (form.dataset.busy === 'true') return; 841 const data = Object.fromEntries(new FormData(form)); 842 const submit = form.querySelector('button[type=submit]'); 843 form.dataset.busy = 'true'; if (submit) submit.disabled = true; 844 const errorNode = document.querySelector('#modalError'); if (errorNode) errorNode.textContent = ''; 845 try { 846 if (form.id === 'literatureForm') { 847 if (data.range === 'custom' && (!data.start || !data.end || data.start > data.end)) throw new Error('è¯·å¡«åææçå¼å§åç»ææ¥æ'); 848 await searchOnlinePapers(data.query, data.source, data.range === 'custom' ? { start: data.start, end: data.end } : data.range); return; 849 } 850 if (form.id === 'noteForm') { 851 const list = notes(), index = list.findIndex(n => n.id === form.dataset.id); 852 const note = { ...(index >= 0 ? list[index] : {}), id: form.dataset.id || uid(), title: data.title.trim(), content: data.content.trim(), tag: data.tag.trim(), updatedAt: new Date().toISOString() }; 853 if (!note.title || !note.content) throw new Error('æ é¢åæ£æä¸è½ä¸ºç©º'); 854 if (index >= 0) list[index] = note; else list.push(note); 855 write(noteKey(), list); 856 } else if (form.id === 'healthRecordForm') { 857 const h = getHealth(), row = { ...healthRecord(data), id: form.dataset.id || uid(), createdAt: new Date().toISOString() }; 858 const index = h.timeline.findIndex(r => r.id === form.dataset.id); 859 if (index >= 0) h.timeline[index] = row; else h.timeline.unshift(row); saveHealth(h); 860 } else if (form.id === 'medicationForm') { 861 const h = getHealth(); h.medications.push({ ...data, id: uid(), doneDates: [] }); saveHealth(h); 862 } else if (form.id === 'newPaperForm') { 863 const paper = { id: uid(), title: data.title.trim(), zh: data.title.trim(), abstract: data.abstract, url: urlSafe(data.url), meta: 'æå¨å¯¼å ¥', status: 'å·²ä¿å', scope: state.currentSpace === 'shared' ? 'shared' : 'personal' }; 864 if (!paper.title) throw new Error('è¯·å¡«åæ é¢'); 865 if (data.file?.size) { 866 if (data.file.size > 30 * 1024 * 1024) throw new Error('PDFä¸è½è¶ è¿30MB'); 867 const parsed = await api('/api/parse-pdf', { filename: data.file.name, contentBase64: bytesToBase64(await data.file.arrayBuffer()) }); 868 Object.assign(paper, parsed, { pdfName: data.file.name, pdfVerified: true });
869 } 870 papers.unshift(paper); try { savePapers(); } catch (error) { papers.shift(); throw error; } 871 state.selectedPaper = paper.id; state.readerTab = data.file?.size ? 'pdf' : 'original'; state.view = 'papers'; 872 } else if (form.id === 'attachPdfForm') await attachPdf(data.file, getSelectedPaper()); 873 else if (form.id === 'guestbookForm') { 874 const editing = !!form.dataset.id; 875 const result = await api(editing ? '/api/guestbook/edit/' + encodeURIComponent(form.dataset.id) : '/api/guestbook', data); 876 guestbookYear = result.item?.year || ''; state.guestbookYearRoute = guestbookYear || 'all'; guestbookPage = 0; 877 history.replaceState(null, '', routeHash()); closePaperModal(); await loadGuestbook(); 878 if (editing) openGuestbook(result.item.id); 879 toast(editing ? 'çè¨å·²ä¿®æ¹' : 'çè¨å·²åå¸ï¼å¯æå¼å¡çä¿®æ¹æå é¤'); return; 880 } 881 else if (form.id === 'guestbookDeleteForm') { 882 const prefix = galleryState.admin ? '/api/gallery/guestbook/trash/' : '/api/guestbook/trash/'; 883 await api(prefix + encodeURIComponent(form.dataset.id), {}); 884 closePaperModal(); await loadGuestbook(); toast('çè¨å·²ç§»å ¥åæ¶ç«'); return; 885 } 886 else if (form.id === 'guestbookReplyForm') { 887 const result = await api('/api/gallery/guestbook/reply/' + encodeURIComponent(form.dataset.id), data); 888 const row = guestbookItems.find(item=>item.id===form.dataset.id); 889 if (row) Object.assign(row, result.item, { liked: row.liked, owned: row.owned }); 890 const id = form.dataset.id; closePaperModal(); renderView(); openGuestbook(id); toast('åå¤å·²ä¿å'); return; 891 } 892 else if (form.id === 'managerForm') { 893 await api('/api/gallery/session', { password: data.password }); 894 if (form.dataset.guestbook) { const id = form.dataset.id; form.reset(); closePaperModal(); await loadGuestbook(); if (id) openGuestbook(id); else guestbookManagement(); toast('å·²è¿å ¥çè¨ç®¡ç'); return; } 895 form.reset(); await loadGallery(); 896 } 897 else if (form.id === 'albumForm') { await api('/api/gallery/albums', { ...data, ...(form.dataset.id ? { id: form.dataset.id } : {}) }); await loadGallery(); } 898 else if (form.id === 'photoEditForm') { await api('/api/gallery/edit', { ...data, filename: form.dataset.file }); await loadGallery(); } 899 else if (form.id === 'photoUploadForm') { 900 const files = form._pendingFiles || [...form.elements.files.files]; 901 if (!files.length) throw new Error('è¯·éæ©ç §ç'); 902 if (files.length > 100) throw new Error('æ¯æ¹æå¤100å¼ ç §ç'); 903 if (files.some(f => f.size > 8 * 1024 * 1024)) throw new Error('æç §çè¶ è¿8MBï¼è¯·å 缩å°å¾ç'); 904 const failed = []; 905 for (let i = 0; i < files.length; i++) { 906 document.querySelector('#uploadStatus').textContent = `æ£å¨ä¸ä¼ ${i + 1} / ${files.length}ï¼${files[i].name}`; 907 try { 908 await api('/api/gallery/upload', { albumId: data.albumId, title: data.title.trim() ? data.title.trim() + (files.length > 1 ? ` ${i + 1}` : '') : files[i].name.replace(/\.[^.]+$/, ''), date: data.date, uploader: data.uploader, description: data.description, contentBase64: bytesToBase64(await files[i].arrayBuffer()) }); 909 } catch (error) { failed.push(files[i]); } 910 } 911 form._pendingFiles = failed; await loadGallery(); 912 if (failed.length) throw new Error(`å·²æåä¸ä¼ ${files.length - failed.length}å¼ ï¼${failed.length}å¼ å¤±è´¥ã忬¡ç¹å»ä¸ä¼ åªä¼éè¯å¤±è´¥ç §çã`); 913 state.albumId = data.albumId; state.galleryQuery = ''; galleryState.trash = false; 914 } else if (form.id === 'backupForm') return await importBackup(data.file); 915 else if (form.id === 'mobileSearchForm') { state.searchQuery = data.query; return navigate('search'); } 916 closePaperModal(); renderView(); history.replaceState(null, '', routeHash()); toast('å·²ä¿å'); 917 } catch (error) { showError(error); }
918 finally { form.dataset.busy = 'false'; if (submit?.isConnected) submit.disabled = false; } 919 }, true); 920 921 document.addEventListener('change', event => { 922 const el = event.target; 923 try { 924 if (el.id === 'recordType') document.querySelector('#healthFields').innerHTML = healthFields(el.value); 925 if (el.id === 'literatureRange') document.querySelector('#customDates').hidden = el.value !== 'custom'; 926 if (el.id === 'healthRange') { healthDays = el.value; renderView(); } 927 if (el.id === 'healthType') { healthType = el.value; renderView(); } 928 if (el.id === 'favoriteOnly') { favoriteOnly = el.checked; renderView(); } 929 if (el.id === 'galleryYear') { state.galleryYear = el.value; navigate('gallery'); } 930 if (el.id === 'citySelector') { state.currentCity = el.value; renderView(); } 931 if (el.id === 'paperGroup') { const paper = getSelectedPaper(); paper.projectId = el.value; savePapers(); toast('åç»å·²ä¿å'); } 932 if (el.dataset.projectName || el.dataset.projectDesc) { 933 const group = projectGroups.find(g => g.id === (el.dataset.projectName || el.dataset.projectDesc)); 934 if (el.dataset.projectName && !el.value.trim()) throw new Error('åç»åç§°ä¸è½ä¸ºç©º'); 935 group[el.dataset.projectName ? 'title' : 'desc'] = el.value.trim(); saveProjectGroups(); toast('å·²ä¿å'); 936 } 937 } catch (error) { showError(error); } 938 }); 939 document.addEventListener('input', event => { 940 const el = event.target; 941 if (!['noteQuery', 'galleryQuery', 'libraryQuery'].includes(el.id)) return; 942 if (el.id === 'noteQuery') noteQuery = el.value; 943 if (el.id === 'galleryQuery') state.galleryQuery = el.value; 944 if (el.id === 'libraryQuery') state.paperQuery = el.value; 945 const position = el.selectionStart, id = el.id; renderView(); 946 const next = document.getElementById(id); next?.focus(); 947 try { next?.setSelectionRange(position, position); } catch {} 948 }); 949 document.addEventListener('keydown', event => { 950 if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { 951 event.preventDefault(); event.stopImmediatePropagation(); 952 if (matchMedia('(max-width:900px)').matches) runAction('mobile-search'); else document.querySelector('#globalSearch').focus(); 953 } 954 if (event.target.id === 'globalSearch' && event.key === 'Enter') { 955 event.preventDefault(); event.stopImmediatePropagation(); state.searchQuery = event.target.value; navigate('search'); 956 } 957 if (event.key === 'Escape' && !document.querySelector('.pswp')) { closePaperModal(); setMobileSidebar(false); document.querySelector('#profileMenu').hidden = true; } 958 if (event.key === 'Tab' && modalRoot.firstChild) { 959 const focusable = [...modalRoot.querySelectorAll('button,a[href],input,select,textarea,[tabindex="0"]')].filter(el => !el.disabled && el.getClientRects().length); 960 const first = focusable[0], last = focusable.at(-1); 961 if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } 962 else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } 963 } 964 }, true); 965 new MutationObserver(() => { 966 const dialog = modalRoot.querySelector('[role="dialog"]'); 967 document.querySelector('#app').inert = !!dialog; 968 document.body.classList.toggle('modal-open', !!dialog); 969 }).observe(modalRoot, { childList: true }); 970 971 bind = () => {}; 972 renderView = function () { 973 disposeGarden?.(); disposeGarden = null; 974 disposeFilmstrip?.(); disposeFilmstrip = null; 975 chart?.destroy(); chart = null; 976 const renderers = { home: state.currentSpace === 'landing' ? renderLanding : renderHome, papers: renderPapers, projects: renderProjects, notes: renderNotes, health: renderHealth, food: renderFood, patents: renderPatents, shared: renderShared, gallery: renderGallery, settings: renderSettings, help: renderHelp, search: renderSearch, guestbook: renderGuestbook }; 977 main.innerHTML = (renderers[state.view] || renderHome)(); 978 navigation(); icons(); 979 if (publicHome()) bindFilmstrip(); 980 if (state.view === 'guestbook') disposeGarden = window.GuestbookGarden.mount(main); 981 if (state.view === 'health') { renderTrend(); bindWeatherPanel(); } 982 if (state.view === 'food') drawFoodWheel(); 983 }; 984 function restoreRoute() { 985 const route = LabCore.route(location.hash); state.guestbookYearRoute = route.guestbookYearRoute || ''; 986 Object.assign(state, route); 987 if (state.view === 'guestbook') { guestbookYear = state.guestbookYearRoute === 'all' ? '' : state.guestbookYearRoute || null; guestbookPage = 0; if (guestbookYear === null && guestbookLoaded) guestbookYear = guestbookItems.map(row=>
987row.year).filter(Boolean).sort().reverse()[0] || ''; } 988 if (state.view === 'guestbook' && location.hash !== routeHash()) history.replaceState(null, '', routeHash()); 989 state.galleryQuery = ''; galleryState.trash = false; 990 setMobileSidebar(false); closePaperModal(); renderView(); 991 if (publicHome() || (['gallery', 'shared'].includes(state.view) && !galleryState.loaded)) loadGallery(); 992 if (state.view === 'guestbook' && !guestbookLoaded) loadGuestbook(); 993 } 994 window.addEventListener('hashchange', restoreRoute); 995 // pushState navigation is restored by popstate; ordinary hash links are handled above. 996 window.addEventListener('popstate', restoreRoute); 997 restoreRoute(); 998 window.SiteBoot?.ready(); 999})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.