PageSourceSearch

https://bianjun-jou202203.hf.space/site.js?v=20260911-fast-v8

js bianjun-jou202203.hf.space collected 2026-10-03 16:43:49 UTC 104,852 bytes, 999 lines download raw bytes

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.