;(function(){ const el = id => document.getElementById(id) const $auth = el('auth'), $status = el('auth-status'), $app = el('weekly-app') const $year = el('week-year'), $weeks = el('week-list') const $title = el('report-title'), $summary = el('report-summary'), $content = el('report-content') const $events = el('events') const $search = el('weekly-search'), $settings = el('weekly-settings') let current = { report: null, events: [] } let currentEditId = null let eventsLoadSeq = 0 const apiBase = '/api/weekly' function weeklyTemplateHtml() { return [ '

1、本周核心重点事项(置顶)

', '

①置顶优先级,避免杂事分散精力

', '

②本周 1-3 件最高优先级事(工作 / 家庭各列,如公司项目节点、孩子升学事宜)

', '

③必做底线项(如固定会议、家庭日常、记账 / 复盘打卡)

', '


', '

2、上周复盘(工作)

', '

-----

', '

①公司工作完成项(标注是否达标、核心成果)

', '

②未完成项 + 原因(客观 / 主观,如时间不足、需求变更)

', '

③规则事务完成情况(重点记未完成的关键项)

', '


', '

3、上周复盘(个人 + 家庭)

', '

-----

', '

①个人 / 家庭事务完成情况(重点记未完成的关键项)

', '


', '

4、公司工作(计划)

', '

-----

', '

①核心任务(按重要 / 紧急排序,标注截止时间、协作人)

', '

②常规工作(日常运维、对接、汇报等固定事项)

', '

③待协调 / 需支持项(如需要同事配合、领导审批的内容)

', '


', '

5、个人和家庭 事务(计划,含理财)

', '

-----

', '

①自我提升(学习、运动、习惯打卡,如读 1 章书、运动 3 次)

', '

②个人琐事(办事、缴费、社交等,标注时间节点)

', '

③投资理财(盯盘节点、条件单调整、复盘上周收益 / 操作)

', '

④家人相关(孩子学习 / 活动、老人照料、家庭聚餐等)

', '

⑤家庭琐事(家务、采购、物业对接等,可标注分工)

', '

⑥家庭规划(短期家庭事项,如周末出游、家电维修)

', '


', '

6、待解决问题 / 待协调项

', '

-----

', '

①工作中可能遇到的问题(如项目延期风险、资源不足)

', '

②个人 / 家庭的待处理问题(如时间冲突、资金安排)

', '

③初步应对思路(简单标注,避免临时慌乱)

', '


', '

7、本周小目标 / 打卡项(轻量化激励,易落地)

', '

①工作小目标(如高效完成 1 个核心任务、减少无效沟通)

', '

②个人 / 家庭小目标(如坚持记账、周末全家运动 1 次)

' ].join('') } function insertWeeklyTemplate(options) { if (!currentEditor) return const tpl = weeklyTemplateHtml() const replace = !options || options.replace !== false if (replace) { const hasContent = String(currentEditor.innerHTML || '').trim().length > 0 if (hasContent) { const ok = confirm('正文已有内容,是否覆盖为模板?') if (!ok) return } currentEditor.innerHTML = tpl } else { currentEditor.innerHTML += tpl } currentEditor.focus() } async function login() { try { const user = el('login-user').value.trim() const pass = el('login-pass').value const res = await fetch(apiBase + '/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ username:user, password:pass }), credentials:'same-origin', cache:'no-store' }) let data = null const ct = String(res.headers.get('content-type')||'') if (ct.includes('application/json')) { try { data = await res.json() } catch {} } if (!data) { try { const txt = await res.clone().text() const brief = String(txt||'').replace(/\s+/g,' ').slice(0,300) throw new Error(`HTTP ${res.status} ${res.statusText} @ ${res.url || '/api/weekly/auth/login'}: ${brief}`) } catch (e2) { throw new Error(`HTTP ${res.status} ${res.statusText} @ ${res.url || '/api/weekly/auth/login'}: 非JSON响应且文本读取失败`) } } if (!data.ok) throw new Error(data.error||`登录失败 (HTTP ${res.status})`) $auth.style.display = 'none' $app.style.display = '' initWeeks() } catch (e) { $status.textContent = String(e.message||e) $status.style.display = '' } } async function initWeeks() { const now = new Date() const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7) const startYear = now.getFullYear() const endYear = next.getFullYear() let years = [] try { const res = await fetch(`${apiBase}/years`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() if (data && data.ok && Array.isArray(data.years)) { years = data.years.map(y => parseInt(String(y),10)).filter(y => y > 0) } } catch {} if (!years.length) { years = [] for (let y = endYear; y >= startYear; y--) years.push(y) } $year.innerHTML = '' years.forEach((y, idx) => { const opt = document.createElement('option') opt.value = String(y) opt.textContent = String(y) $year.appendChild(opt) }) const cnYear = parseInt(new Intl.DateTimeFormat('zh-CN', { timeZone:'Asia/Shanghai', year:'numeric' }).format(new Date()), 10) if (years.includes(cnYear)) $year.value = String(cnYear) else $year.value = String(years.length ? years[0] : startYear) const initialYear = parseInt($year.value, 10) await runWithLoading(()=>loadWeeks(initialYear), '周列表加载成功') } async function loadWeeks(year) { try { addLoadingStep(`并发请求 ${year-1}, ${year}, ${year+1} 周列表`) } catch {} const [rPrev, rCurr, rNext] = await Promise.all([ fetch(`${apiBase}/weeks?year=${encodeURIComponent(year-1)}`, { credentials:'same-origin', cache:'no-store' }), fetch(`${apiBase}/weeks?year=${encodeURIComponent(year)}`, { credentials:'same-origin', cache:'no-store' }), fetch(`${apiBase}/weeks?year=${encodeURIComponent(year+1)}`, { credentials:'same-origin', cache:'no-store' }) ]) const [dPrev, dCurr, dNext] = await Promise.all([ rPrev.json().catch(()=>({weeks:[]})), rCurr.json().catch(()=>({weeks:[]})), rNext.json().catch(()=>({weeks:[]})) ]) try { const tPrev = Array.isArray(dPrev.weeks)?dPrev.weeks.length:0 const tCurr = Array.isArray(dCurr.weeks)?dCurr.weeks.length:0 const tNext = Array.isArray(dNext.weeks)?dNext.weeks.length:0 addLoadingStep(`汇总周列表:${tPrev}+${tCurr}+${tNext} 条`) } catch {} const listAll = ([]).concat(Array.isArray(dPrev.weeks)?dPrev.weeks:[], Array.isArray(dCurr.weeks)?dCurr.weeks:[], Array.isArray(dNext.weeks)?dNext.weeks:[]) const fmt = new Intl.DateTimeFormat('zh-CN', { timeZone:'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit' }) const today = fmt.format(new Date()).replace(/\//g,'-') let idx = -1 for (let i=0;i=0 && idx+1=0) nextEnd = String(listAll[idx].end) const subset = listAll.filter(w => String(w.end) >= yearStart && (!nextEnd || String(w.end) <= nextEnd)) subset.sort((a,b)=>String(a.start).localeCompare(String(b.start))) const display = subset.slice().reverse() $weeks.innerHTML = '' try { addLoadingStep(`渲染周列表 ${display.length} 条`) } catch {} const currentId = (idx>=0 && listAll[idx]) ? listAll[idx].week_id : '' const nextId = (idx>=0 && idx+1 { const opt = document.createElement('option'); opt.value = w.week_id const type = w.week_id===currentId ? 'current' : (w.week_id===nextId ? 'next' : 'past') const prefix = type==='current' ? '📗 ' : (type==='next' ? '📙 ' : '📘 ') const icon = (w.has_report && w.has_events) ? ' 📅💬' : (w.has_report ? ' 📅' : (w.has_events ? ' 💬' : '')) const base = `${prefix}${w.week_id} (${w.start}~${w.end})${icon}` opt.textContent = base opt.dataset.baseLabel = base if (w.week_id === currentId) hasCurrent = true $weeks.appendChild(opt) }) if (hasCurrent) { $weeks.value = currentId } else if (display.length) { $weeks.value = display[0].week_id } try { addLoadingStep(`加载当前周报 ${$weeks.value||''}`) } catch {} await loadReport($weeks.value) } async function loadReport(week_id) { try { addLoadingStep(`请求周报数据 week_id=${week_id}`) } catch {} const res = await fetch(`${apiBase}/report?week_id=${encodeURIComponent(week_id)}`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() current.report = data.report || null $title.value = current.report ? (current.report.title||'') : '' $summary.value = current.report ? (current.report.summary||'') : '' $content.innerHTML = current.report ? (current.report.content||'') : '' try { addLoadingStep('渲染周报内容') } catch {} try { addLoadingStep('加载事件列表') } catch {} await loadEvents() } async function saveReport() { const week_id = $weeks.value const payload = { week_id, title: $title.value, summary: $summary.value, content: $content.innerHTML } if (current.report && current.report.id) { await fetch(`${apiBase}/report/${current.report.id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload), credentials:'same-origin', cache:'no-store' }) } else { const res = await fetch(`${apiBase}/report`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload), credentials:'same-origin', cache:'no-store' }) const data = await res.json(); current.report = { id: data.id, week_id } } await loadReport(week_id) } const nextPaint = () => new Promise(resolve => requestAnimationFrame(() => resolve())) async function renderEventsChunked(events, seq) { const chunkSize = 20 for (let i = 0; i < events.length; i += chunkSize) { if (seq !== eventsLoadSeq) return false const chunk = events.slice(i, i + chunkSize) chunk.forEach(ev => renderEvent(ev)) try { addLoadingStep(`事件卡片渲染进度 ${Math.min(i + chunk.length, events.length)}/${events.length}`) } catch {} await nextPaint() } return true } async function loadEventExtraData(events, seq) { const eventIds = events.map(ev => ev.id) try { addLoadingStep(`批量加载 ${events.length} 个事件详情(评价与附件)...`) } catch {} const [reviewsByEventId, attachmentsByEventId] = await Promise.all([ fetchReviewsBatch(eventIds), fetchAttachmentsBatch(eventIds) ]) if (seq !== eventsLoadSeq) return for (const ev of events) { if (seq !== eventsLoadSeq) return const reviews = document.querySelector(`.reviews[data-event-id="${ev.id}"]`) const atts = document.querySelector(`.attachments[data-event-id="${ev.id}"]`) if (reviews) renderReviews(ev.id, reviews, ev.title, reviewsByEventId[ev.id] || []) if (atts) renderAttachments(ev.id, atts, ev.title, attachmentsByEventId[ev.id] || []) } try { addLoadingStep('所有事件详情加载完毕') } catch {} } async function loadEvents() { const seq = ++eventsLoadSeq try { addLoadingStep(`请求事件列表 report_id=${(current.report && current.report.id) ? current.report.id : ''}`) } catch {} $events.innerHTML = '' if (!current.report || !current.report.id) return const res = await fetch(`${apiBase}/events?report_id=${current.report.id}`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() if (seq !== eventsLoadSeq) return current.events = data.rows || [] try { addLoadingStep(`渲染事件卡片 ${current.events.length} 条`) } catch {} const rendered = await renderEventsChunked(current.events, seq) if (!rendered) return try { addLoadingStep('事件卡片渲染完成') } catch {} void loadEventExtraData(current.events.slice(), seq) } function statusLabel(code) { return ({ doing:'▶️进行中', done:'✔️完成', pause:'⏸️暂停', new:'📅本周新增', close:'❌关闭', not:'👋未做', ok:'👌成功', fail:'⛔失败' })[code] || code } const TITLE_INLINE_HAN_LIMIT = 50 const normalizeInlineText = text => String(text || '').replace(/\s+/g, ' ').trim() const normalizeBodyForTitle = content => normalizeInlineText(String(content || '').replace(//gi, ';').replace(/<[^>]+>/g, ' ').replace(/\r?\n+/g, ';')) const countHanChars = text => (String(text || '').match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length const countDisplayChars = text => Array.from(String(text || '')).length const takePrefixByHanLimit = (text, hanLimit) => { const chars = Array.from(String(text || '')) let han = 0 let out = '' for (const ch of chars) { const isHan = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/.test(ch) if (isHan) { if (han >= hanLimit) break han += 1 } out += ch } return out.trim() } const hasTitlePolicyApplied = (title, detail) => { const t = normalizeInlineText(title) const d = normalizeInlineText(detail) if (!d) return true if (t.includes('【剩余') && t.includes('详见正文】')) return true if (d.length >= 10 && t.includes(d.slice(0, 10))) return true return false } const buildEventTitleByPolicy = (title, content) => { const base = normalizeInlineText(title) const detail = normalizeBodyForTitle(content) if (!detail || hasTitlePolicyApplied(base, detail)) return base const hanCount = countHanChars(detail) const effectiveCount = hanCount > 0 ? hanCount : countDisplayChars(detail) if (effectiveCount <= TITLE_INLINE_HAN_LIMIT) return normalizeInlineText(`${base} ${detail}`) const prefix = hanCount > 0 ? takePrefixByHanLimit(detail, TITLE_INLINE_HAN_LIMIT) : Array.from(detail).slice(0, TITLE_INLINE_HAN_LIMIT).join('') const remaining = Math.max(0, effectiveCount - TITLE_INLINE_HAN_LIMIT) return normalizeInlineText(`${base} ${prefix}【剩余${remaining}汉字,详见正文】`) } function renderEvent(ev) { const wrap = document.createElement('div'); wrap.className = 'event'; wrap.dataset.id = ev.id wrap.dataset.status = ev.status_code || '' // 事件头部 const header = document.createElement('div'); header.className = 'event-header'; const titleWrapper = document.createElement('div'); titleWrapper.className = 'event-title-wrapper'; const tag = document.createElement('span'); tag.className='tag'; tag.textContent = statusLabel(ev.status_code); const titleSpan = document.createElement('div'); titleSpan.className='event-title'; titleSpan.textContent = buildEventTitleByPolicy(ev.title || '', ev.content || '') titleWrapper.appendChild(tag); titleWrapper.appendChild(titleSpan); header.appendChild(titleWrapper); // 事件元数据 const meta = document.createElement('div'); meta.className = 'event-meta'; if (ev.deadline) { const deadlineItem = document.createElement('div'); deadlineItem.className = 'event-meta-item'; deadlineItem.innerHTML = `⏰截止时间:${formatCNDeadline(ev.deadline)}`; meta.appendChild(deadlineItem); } const pushItem = document.createElement('div'); pushItem.className = 'event-meta-item'; pushItem.innerHTML = `📧推送:${ev.push_required ? '是' : '否'}`; meta.appendChild(pushItem); // 事件内容 const body = document.createElement('div'); body.className='event-body'; body.innerHTML = ev.content||''; // 事件操作 const actions = document.createElement('div'); actions.className = 'event-actions'; const reviewBtn = document.createElement('button'); reviewBtn.className='btn'; reviewBtn.textContent='💬 评价'; reviewBtn.onclick = () => addReview(ev.id); const attachInpWrapper = document.createElement('div'); attachInpWrapper.className = 'file-input-wrapper'; const attachInp = document.createElement('input'); attachInp.type='file'; attachInp.multiple = true; attachInp.onchange = () => uploadAttachments(ev.id, Array.from(attachInp.files||[])); const attachBtn = document.createElement('button'); attachBtn.className='btn'; attachBtn.textContent='📎 附件'; attachBtn.onclick = () => attachInp.click(); attachInpWrapper.appendChild(attachInp); attachInpWrapper.appendChild(attachBtn); const editBtn = document.createElement('button'); editBtn.className='btn'; editBtn.textContent='✏️ 编辑'; editBtn.onclick = () => startEdit(ev); const delBtn = document.createElement('button'); delBtn.className='btn'; delBtn.textContent='🗑️ 删除'; delBtn.onclick = () => deleteEvent(ev.id); const calBtn = document.createElement('button'); calBtn.className='btn'; calBtn.textContent='📅 手工推至日历'; calBtn.onclick = () => { try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.push.click', data:{ event_id: ev.id } }), credentials:'same-origin', cache:'no-store' }) } catch {} const hasDeadline = !!ev.deadline if (!hasDeadline) { const msg = '请设置“截止时间”。' try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.push.invalid', data:{ event_id: ev.id, hasDeadline, msg } }), credentials:'same-origin', cache:'no-store' }) } catch {} showModal('条件不满足', msg) return } pushCalendar(ev.id) }; actions.appendChild(reviewBtn); actions.appendChild(attachInpWrapper); actions.appendChild(editBtn); actions.appendChild(delBtn); actions.appendChild(calBtn); // 组装事件 wrap.appendChild(header); wrap.appendChild(meta); wrap.appendChild(body); wrap.appendChild(actions); // 评价和附件区域 const reviews = document.createElement('div'); reviews.className = 'reviews'; reviews.setAttribute('data-event-id', ev.id); const atts = document.createElement('div'); atts.className = 'attachments'; atts.setAttribute('data-event-id', ev.id); wrap.appendChild(reviews); wrap.appendChild(atts); $events.appendChild(wrap) } async function addEvent() { const payload = { report_id: current.report && current.report.id ? current.report.id : null, title: el('ev-title').value, content: el('ev-content').innerHTML, status_code: el('ev-status').value, status: statusLabel(el('ev-status').value), deadline: (el('ev-deadline').value || '').replace('T',' ') || null, push_required: (el('ev-push') && el('ev-push').checked) ? 1 : 0 } try { if (currentEditId) { const body = Object.assign({}, payload, { id: currentEditId }) const res = await fetch(`${apiBase}/events/${currentEditId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body), credentials:'same-origin', cache:'no-store' }) let ok = res.ok try { const ct = String(res.headers.get('content-type')||'') if (ct.includes('application/json')) { const dj = await res.json(); ok = !!dj.ok } } catch {} if (!ok) throw new Error(`保存失败 (HTTP ${res.status})`) currentEditId = null const btn = el('btn-add-event'); if (btn) btn.textContent = '新增事件' showModal('操作成功', '事件已保存') } else { const res = await fetch(`${apiBase}/events`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload), credentials:'same-origin', cache:'no-store' }) let ok = res.ok try { const ct = String(res.headers.get('content-type')||'') if (ct.includes('application/json')) { const dj = await res.json(); ok = !!dj.ok } } catch {} if (!ok) throw new Error(`保存失败 (HTTP ${res.status})`) showModal('操作成功', '事件已保存') } } catch (e) { alert(String(e.message||e)) return } el('ev-title').value = ''; el('ev-content').innerHTML=''; el('ev-deadline').value=''; if (!current.report || !current.report.id) { await runWithLoading(() => loadReport($weeks.value), '周报加载成功') } else { await runWithLoading(loadEvents, '事件列表加载成功') } } function toInputValue(ts) { if (!ts) return ''; const s = String(ts).replace(' ', 'T'); return s.length>=16 ? s.slice(0,16) : s } function formatCNDeadline(ts) { if (!ts) return ''; const s = String(ts).replace('T',' '); return s.length>=19 ? s.slice(0,19) : (s.length>=16 ? s.slice(0,16) : s) } function openEventModal(mode, ev) { const ov = el('modal-overlay'), mt = el('modal-title'), mb = el('modal-body') const isEdit = mode === 'edit' mt.textContent = isEdit ? '编辑事件' : '新增事件' const options = ` ` mb.innerHTML = `
` bindPasteAndDrop(el('modal-ev-content')) el('modal-ev-push').onchange = () => { if (el('modal-ev-push').checked && !el('modal-ev-deadline').value) { alert('请先填写“截止时间”,否则无法自动推送') } } if (isEdit && ev) { el('modal-ev-title').value = ev.title || '' el('modal-ev-content').innerHTML = ev.content || '' el('modal-ev-status').value = ev.status_code || 'doing' el('modal-ev-deadline').value = toInputValue(ev.deadline || '') el('modal-ev-push').checked = !!ev.push_required } else { el('modal-ev-status').value = 'doing' el('modal-ev-deadline').value = '' el('modal-ev-title').value = '' el('modal-ev-content').innerHTML = '' el('modal-ev-push').checked = false } ov.classList.add('active') const modalNode = document.getElementById('modal') if (modalNode) modalNode.style.display = 'block' const mf = document.getElementById('modal-footer') if (mf) { mf.innerHTML = '' const cancelBtn = document.createElement('button'); cancelBtn.className='btn btn-secondary'; cancelBtn.textContent='取消' cancelBtn.onclick = () => { hideModal() } const confirmBtn = document.createElement('button'); confirmBtn.className='btn'; confirmBtn.textContent='保存' mf.appendChild(cancelBtn); mf.appendChild(confirmBtn) confirmBtn.onclick = async () => { const payload = { report_id: current.report && current.report.id ? current.report.id : null, title: el('modal-ev-title').value, content: el('modal-ev-content').innerHTML, status_code: el('modal-ev-status').value, status: statusLabel(el('modal-ev-status').value), deadline: (el('modal-ev-deadline').value || '').replace('T',' ') || null, push_required: el('modal-ev-push').checked ? 1 : 0 } try { if (isEdit && ev) { const body = Object.assign({}, payload, { id: ev.id }) const res = await fetch(`${apiBase}/events/${ev.id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body), credentials:'same-origin', cache:'no-store' }) let ok = res.ok try { const ct = String(res.headers.get('content-type')||''); if (ct.includes('application/json')) { const dj = await res.json(); ok = !!dj.ok } } catch {} if (!ok) throw new Error(`保存失败 (HTTP ${res.status})`) } else { const res = await fetch(`${apiBase}/events`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload), credentials:'same-origin', cache:'no-store' }) let ok = res.ok try { const ct = String(res.headers.get('content-type')||''); if (ct.includes('application/json')) { const dj = await res.json(); ok = !!dj.ok } } catch {} if (!ok) throw new Error(`保存失败 (HTTP ${res.status})`) } } catch (e) { alert(String(e.message||e)) return } hideModal() if (!current.report || !current.report.id) { await runWithLoading(() => loadReport($weeks.value), '周报加载成功') } else { await runWithLoading(loadEvents, '事件列表加载成功') } showModal('操作成功', '事件已保存') } } } function startEdit(ev) { openEventModal('edit', ev) } async function deleteEvent(id) { const node = document.querySelector(`.event[data-id="${id}"]`) if (!confirm('确认删除该事件?')) return try { const res = await fetch(`${apiBase}/events/${id}`, { method:'DELETE', credentials:'same-origin', cache:'no-store' }) let ok = res.ok let msg = '' try { const ct = String(res.headers.get('content-type')||'') if (ct.includes('application/json')) { const data = await res.json() ok = !!data.ok msg = String(data.error||'') } } catch {} if (!ok) { alert(msg || `删除失败 (HTTP ${res.status})`) return } if (node) { node.style.opacity = '0.4'; node.style.transform = 'scale(0.98)' } await runWithLoading(loadEvents, '事件列表加载成功') showModal('操作成功', '事件已删除') } catch (e) { alert(`删除失败:${String(e.message||e)}`) } } async function linkCurrentEvent() { const target = prompt('请输入要关联的目标事件ID:') const tid = parseInt(String(target||''),10) if (!tid || !current.events || current.events.length===0) return const last = current.events[current.events.length-1] const res = await fetch(`${apiBase}/relations`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ event_id: last.id, target_event_id: tid, relation_type: 'link' }), credentials:'same-origin', cache:'no-store' }) const data = await res.json(); if (!data.ok) { alert('关联失败'); return } showModal('操作成功', '已建立关联') } async function addReview(event_id) { await runWithLoading(async () => { let title = '' try { if (current && Array.isArray(current.events)) { const ev = current.events.find(e => e.id === event_id) if (ev && ev.title) title = ev.title } } catch {} const shortTitle = title ? `${title.slice(0, 10)}${title.length > 10 ? '...' : ''}` : '' const ctx = shortTitle ? `事件ID=${event_id}, 标题=${shortTitle}` : `事件ID=${event_id}` try { addLoadingStep(`[${ctx}] 步骤 1/5: 打开评价输入框`) } catch {} const text = prompt('请输入评价与感想:') if (!text) { try { addLoadingStep(`[${ctx}] 步骤 1/5: 已取消评价输入`) } catch {} return } try { addLoadingStep(`[${ctx}] 步骤 2/5: 已获取输入,开始提交到服务器...`) } catch {} const res = await fetch(`${apiBase}/reviews`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ event_id, text }), credentials:'same-origin', cache:'no-store' }) try { addLoadingStep(`[${ctx}] 步骤 3/5: 服务器已响应,解析结果...`) } catch {} let ok = res.ok let msg = '' try { const ct = String(res.headers.get('content-type')||'') if (ct.includes('application/json')) { const data = await res.json() ok = !!data.ok msg = String(data.error||'') } } catch {} if (!ok) { try { addLoadingStep(`[${ctx}] 步骤 3/5: 提交失败,错误信息: ${msg || '未知错误'}`) } catch {} throw new Error(msg || `提交失败 (HTTP ${res.status})`) } try { addLoadingStep(`[${ctx}] 步骤 4/5: 提交成功,准备刷新评价列表...`) } catch {} const container = document.querySelector(`.reviews[data-event-id="${event_id}"]`) if (container) { await fetchAndRenderReviews(event_id, container, title) } try { addLoadingStep(`[${ctx}] 步骤 5/5: 评价列表刷新完成`) } catch {} }, '评价保存成功') showModal('操作成功', '已保存评价') } function renderReviews(event_id, container, title, rows) { const t = String(title || '') try { addLoadingStep(`评价已加载: ${t.slice(0,8)}${t.length>8?'...':''} (${rows.length}条)`) } catch {} container.innerHTML = '' if (rows.length === 0) { const empty=document.createElement('div'); empty.className='review-empty'; empty.textContent='暂无评价与感想'; container.appendChild(empty); return } const titleEl = document.createElement('div'); titleEl.className='review-title'; titleEl.textContent='评价与感想'; container.appendChild(titleEl) rows.forEach(r=>{ const item = document.createElement('div'); item.className='review-item' const meta = document.createElement('div'); meta.className='review-meta'; meta.textContent = window.fmtCNDateTime ? window.fmtCNDateTime(new Date(String(r.created_at||'').replace(' ', 'T'))) : String(r.created_at||'') const content = document.createElement('div'); content.className='review-content'; content.textContent = r.review || '' item.appendChild(meta); item.appendChild(content) container.appendChild(item) }) } function renderAttachments(event_id, container, title, rows) { const t = String(title || '') try { addLoadingStep(`附件已加载: ${t.slice(0,8)}${t.length>8?'...':''} (${rows.length}个)`) } catch {} const sortedRows = rows.slice().sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||''))) container.innerHTML = '' const titleEl = document.createElement('div'); titleEl.className='att-title'; titleEl.textContent='附件'; container.appendChild(titleEl) if (sortedRows.length === 0) { const empty=document.createElement('div'); empty.className='att-empty'; empty.textContent='暂无附件'; container.appendChild(empty); return } sortedRows.forEach(a=>{ const item = document.createElement('div'); item.className='att-item' const name = document.createElement('a'); name.href = `${apiBase}/attachments/${a.id}/download`; const mb = ((a.size||0)/1048576).toFixed(2); name.textContent = `${a.filename || '文件'} (${mb}MB)`; name.target = '_blank' item.appendChild(name) container.appendChild(item) }) } async function fetchReviewsBatch(eventIds) { try { if (!Array.isArray(eventIds) || !eventIds.length) return {} const res = await fetch(`${apiBase}/reviews/batch`, { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({ event_ids: eventIds }), credentials:'same-origin', cache:'no-store' }) const data = await res.json() return data.rowsByEventId || {} } catch { return {} } } async function fetchAttachmentsBatch(eventIds) { try { if (!Array.isArray(eventIds) || !eventIds.length) return {} const res = await fetch(`${apiBase}/attachments/batch`, { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({ event_ids: eventIds }), credentials:'same-origin', cache:'no-store' }) const data = await res.json() return data.rowsByEventId || {} } catch { return {} } } async function fetchAndRenderReviews(event_id, container, title='') { try { const res = await fetch(`${apiBase}/reviews?event_id=${event_id}`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() const rows = data.rows||[] renderReviews(event_id, container, title, rows) } catch {} } async function fetchAndRenderAttachments(event_id, container, title='') { try { const res = await fetch(`${apiBase}/attachments?event_id=${event_id}`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() const rows = data.rows||[] renderAttachments(event_id, container, title, rows) } catch {} } async function uploadAttachments(event_id, files) { const container = document.querySelector(`.attachments[data-event-id="${event_id}"]`) let progWrap = null, progBar = null, progText = null if (container) { progWrap = document.createElement('div'); progWrap.className='att-progress' progBar = document.createElement('div'); progBar.className='att-progress-bar' progText = document.createElement('div'); progText.className='att-progress-text' progWrap.appendChild(progBar) container.appendChild(progWrap) container.appendChild(progText) } for (const f of files) { await uploadAttachmentWithProgress(event_id, f, progBar, progText) } const container2 = document.querySelector(`.attachments[data-event-id="${event_id}"]`) if (container2) setTimeout(async ()=>{ await runWithLoading(()=>fetchAndRenderAttachments(event_id, container2), '附件列表加载成功') }, 300) } async function uploadAttachmentWithProgress(event_id, file, progBar, progText) { if (!file) return const size = file.size const chunkSize = 2 * 1024 * 1024 if (size <= chunkSize) { const buf = await file.arrayBuffer() if (progBar) progBar.style.width = '50%' const res = await fetch(`${apiBase}/attachments/upload?event_id=${event_id}&filename=${encodeURIComponent(file.name)}&mime=${encodeURIComponent(file.type)}`, { method:'POST', headers:{ 'Content-Type':'application/octet-stream' }, body: new Uint8Array(buf), credentials:'same-origin', cache:'no-store' }) const data = await res.json(); if (!data.ok) { if (progText) progText.textContent='上传失败'; return } if (progBar) progBar.style.width = '100%' if (progText) progText.textContent = '上传完成' showModal('操作成功', `附件已上传:${file.name}`) return } const total = Math.ceil(size / chunkSize) let uploadId = '' for (let i=0;i '') addLoadingStep(`响应文本: preview="${String(txt||'').replace(/\s+/g,' ').slice(0,300)}"`) } } else { const txt = await res.clone().text().catch(()=> '') addLoadingStep(`非JSON响应: preview="${String(txt||'').replace(/\s+/g,' ').slice(0,300)}"`) } if ((!data || !data.ok) && res.status >= 500) { const url2 = `/api/weekly/summarize` addLoadingStep(`尝试后备接口: ${url2}`) const res2 = await fetch(url2, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: bodyStr, credentials: 'same-origin', cache: 'no-store' }) addLoadingStep(`后备HTTP状态: ${res2.status} ${res2.statusText}`) const ct2 = String(res2.headers.get('content-type')||'') addLoadingStep(`后备Content-Type: ${ct2}`) if (ct2.includes('application/json')) { try { data = await res2.clone().json() const preview2 = JSON.stringify(data).slice(0,300) addLoadingStep(`后备响应JSON: preview="${preview2}"`) } catch (e) { addLoadingStep(`后备JSON解析失败: ${String(e&&e.message||e)}`) const txt2 = await res2.clone().text().catch(()=> '') addLoadingStep(`后备响应文本: preview="${String(txt2||'').replace(/\s+/g,' ').slice(0,300)}"`) } } else { const txt2 = await res2.clone().text().catch(()=> '') addLoadingStep(`后备非JSON响应: preview="${String(txt2||'').replace(/\s+/g,' ').slice(0,300)}"`) } } if (!data) throw new Error(`无有效响应 (HTTP ${res.status})`) if (data.ok && data.summary) { $summary.value = data.summary addLoadingStep('摘要写入完成') try { const modalFooter = document.getElementById('modal-footer') const copySum = document.createElement('button'); copySum.className='btn'; copySum.textContent='复制摘要'; copySum.onclick = () => { try { const s = String($summary.value||'') if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(s).then(()=>{ try { addLoadingStep('已复制摘要到剪贴板') } catch {} }).catch(()=>{}) } else { const ta = document.createElement('textarea'); ta.value = s; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy') } catch {} ; document.body.removeChild(ta); try { addLoadingStep('已复制摘要到剪贴板') } catch {} } } catch {} } modalFooter.appendChild(copySum) } catch {} } else { throw new Error(data.error || `请求失败 (HTTP ${res.status})`) } } catch (e) { addLoadingStep(`请求异常: ${String(e&&e.message||e)}`) throw e } addLoadingStep('流程完成') addLoadingStep('可点击“关闭”继续操作') } catch (e) { try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.summary.error', data:{ ts: Date.now(), message: String(e&&e.message||e) } }), credentials:'same-origin', cache:'no-store' }) } catch {} alert(String(e.message||e)) } finally { busy = false } } el('btn-login').onclick = login ;(async function tryAutoFill() { try { const token = localStorage.getItem('toolbox_creds_token') || '' const url = apiBase + '/auth/local_creds' + (token ? '?token=' + encodeURIComponent(token) : '') const res = await fetch(url, { credentials: 'same-origin', cache: 'no-store' }) const data = await res.json().catch(() => null) if (data && data.ok && data.creds) { if (data.creds.username) el('login-user').value = data.creds.username if (data.creds.password) el('login-pass').value = data.creds.password } } catch {} el('login-user').removeAttribute('readonly') el('login-pass').removeAttribute('readonly') })() function attachAutoSummaryHandlers() { try { const btn = el('btn-auto-summary') if (!btn) { try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.summary.bind.missing', data:{ ts: Date.now() } }), credentials:'same-origin', cache:'no-store' }) } catch {} return } btn.onclick = autoSummarize btn.addEventListener('click', (e)=>{ e.preventDefault(); }) try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.summary.bind.ok', data:{ ts: Date.now() } }), credentials:'same-origin', cache:'no-store' }) } catch {} } catch {} } attachAutoSummaryHandlers() const newReportBtn = document.getElementById('btn-new-report'); if (newReportBtn) newReportBtn.onclick = async () => { await runWithLoading(saveReport, '周报保存成功') } const saveBtn = document.getElementById('btn-save-report'); if (saveBtn) saveBtn.onclick = async () => { await runWithLoading(saveReport, '周报保存成功') } const refreshReportBtn = document.getElementById('btn-refresh-report'); if (refreshReportBtn) refreshReportBtn.onclick = async () => { await runWithLoading(()=>loadReport($weeks.value), '周报已刷新') } const addEventBtn = document.getElementById('btn-add-event'); if (addEventBtn) addEventBtn.onclick = async () => { await runWithLoading(addEvent, '事件保存成功') } const linkBtn = document.getElementById('btn-link-event'); if (linkBtn) linkBtn.onclick = async () => { await runWithLoading(linkCurrentEvent, '已建立关联') } const refreshEventsBtn = document.getElementById('btn-refresh-events'); if (refreshEventsBtn) refreshEventsBtn.onclick = async () => { await runWithLoading(loadEvents, '列表已刷新') } el('week-year').onchange = () => runWithLoading(()=>loadWeeks(parseInt($year.value,10)), '周列表加载成功') el('week-list').onchange = () => runWithLoading(()=>loadReport($weeks.value), '周报加载成功') const btnInsertHeader = document.getElementById('btn-insert-template') if (btnInsertHeader) btnInsertHeader.onclick = () => insertWeeklyTemplate({ replace: true }) // 导航功能 function switchView(viewName) { // 隐藏所有视图 document.querySelectorAll('.view').forEach(view => { view.classList.remove('active'); }); // 显示目标视图 const targetView = document.getElementById(`view-${viewName}`); if (targetView) { targetView.classList.add('active'); if (viewName === 'settings') { try { loadSettingsTabs() } catch {} try { loadAiSettings() } catch {} } } // 更新导航项状态 document.querySelectorAll('.nav-item').forEach(item => { item.classList.remove('active'); }); const activeNav = document.querySelector(`.nav-item[data-view="${viewName}"]`); if (activeNav) { activeNav.classList.add('active'); } } // 绑定导航事件 document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', () => { const viewName = item.dataset.view; switchView(viewName); }); }); try { loadSettingsTabs() } catch {} // 初始化概览数据 async function initOverview() { try { // 视觉反馈:清空或置灰 document.querySelectorAll('.stat-value').forEach(span => span.style.opacity = '0.5'); const bl = document.getElementById('stat-backlog'); if (bl) bl.style.opacity = '0.5'; const res = await fetch(`${apiBase}/overview`, { credentials:'same-origin', cache:'no-store' }); const data = await res.json(); // 恢复透明度 document.querySelectorAll('.stat-value').forEach(span => span.style.opacity = '1'); if (bl) bl.style.opacity = '1'; // 更新统计数据 const stats = data.stats || {}; document.querySelectorAll('.stat-value').forEach(span => { const label = span.nextElementSibling.textContent; if (label === '总事件') span.textContent = stats.totalEvents || 0; else if (label === '进行中') span.textContent = stats.doingEvents || 0; else if (label === '已完成') span.textContent = stats.doneEvents || 0; }); // 更新遗留事项 if (bl) bl.textContent = data.backlog || 0; // 更新趋势图 const trendEl = document.getElementById('trend-chart'); if (trendEl && data.trends) { const max = Math.max(...data.trends.map(t => t.total), 1); trendEl.innerHTML = data.trends.map(t => { const hTotal = (t.total / max) * 80; // max height 80% to leave room for label const hDone = (t.completed / max) * 80; return `
${t.total}
${t.week_id.slice(5)}
` }).join(''); } // 更新热力图 const heatEl = document.getElementById('heatmap-chart'); if (heatEl && data.heatmap) { const map = new Map(data.heatmap.map(h => [h.day, h.count])); let html = '
'; // 显示最近60天 for (let i=59; i>=0; i--) { const d = new Date(); d.setDate(d.getDate() - i); // 修正时区问题,使用本地日期字符串 const ymd = new Intl.DateTimeFormat('zh-CN', { year:'numeric', month:'2-digit', day:'2-digit' }).format(d).replace(/\//g,'-'); const count = map.get(ymd) || 0; let level = 0; if (count > 0) level = 1; if (count > 2) level = 2; if (count > 5) level = 3; if (count > 10) level = 4; html += `
`; } html += '
Less More
'; heatEl.innerHTML = html; } // 更新最近活动 const activities = data.recentActivities || []; const activitiesContainer = document.getElementById('recent-activities'); if (activities.length > 0) { activitiesContainer.innerHTML = activities.map(activity => `
${activity.type}
${activity.content}
${String(activity.time).slice(5, 16).replace('T',' ')}
`).join(''); } } catch (error) { console.error('初始化概览失败:', error); } } // 在登录成功后初始化概览 async function login() { try { const user = el('login-user').value.trim() const pass = el('login-pass').value const res = await fetch(apiBase + '/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ username:user, password:pass }), credentials:'same-origin', cache:'no-store' }) let data = null const ct = String(res.headers.get('content-type')||'') if (ct.includes('application/json')) { try { data = await res.json() } catch {} } if (!data) { try { const txt = await res.clone().text() const brief = String(txt||'').replace(/\s+/g,' ').slice(0,300) throw new Error(`HTTP ${res.status} ${res.statusText} @ ${res.url || '/api/weekly/auth/login'}: ${brief}`) } catch (e2) { throw new Error(`HTTP ${res.status} ${res.statusText} @ ${res.url || '/api/weekly/auth/login'}: 非JSON响应且文本读取失败`) } } if (!data.ok) throw new Error(data.error||`登录失败 (HTTP ${res.status})`) $auth.style.display = 'none' $app.style.display = '' initWeeks() initOverview() // 新增:初始化概览 try { attachAutoSummaryHandlers() } catch {} } catch (e) { $status.textContent = String(e.message||e) $status.style.display = '' } } // 模态框功能 let modalAutoCloseTimer = null function showModal(title, content, onConfirm = null, onCancel = null, options = {}) { const modal = document.getElementById('modal'); const overlay = document.getElementById('modal-overlay'); const modalTitle = document.getElementById('modal-title'); const modalBody = document.getElementById('modal-body'); const modalFooter = document.getElementById('modal-footer'); if (modalAutoCloseTimer) { clearTimeout(modalAutoCloseTimer); modalAutoCloseTimer = null } modalTitle.textContent = title; modalBody.innerHTML = content; // 清空之前的按钮 modalFooter.innerHTML = ''; const statusNode = document.createElement('div') statusNode.className = 'modal-status' const actions = document.createElement('div') actions.className = 'modal-actions' modalFooter.appendChild(statusNode) modalFooter.appendChild(actions) // 添加取消按钮 const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn btn-secondary'; cancelBtn.textContent = onConfirm ? '取消' : '关闭'; cancelBtn.onclick = () => { hideModal(); if (onCancel) onCancel(); }; actions.appendChild(cancelBtn); // 添加确认按钮 if (onConfirm) { const confirmBtn = document.createElement('button'); confirmBtn.className = 'btn'; confirmBtn.textContent = '确认'; confirmBtn.onclick = () => { hideModal(); onConfirm(); }; actions.appendChild(confirmBtn); } overlay.classList.add('active'); modal.style.display = 'block'; // 处理自动关闭 // 兼容旧的通过内容判断的方式(虽然我们将要移除它,但为了稳健性保留检查,或者直接优先使用 options) const autoCloseDelay = options.autoClose || (String(content || '').includes('本窗口将在3s后自动关闭') ? 3000 : 0); if (autoCloseDelay > 0) { statusNode.textContent = `本窗口将在${autoCloseDelay/1000}s后自动关闭` modalAutoCloseTimer = setTimeout(() => { hideModal() }, autoCloseDelay) } } function hideModal() { const overlay = document.getElementById('modal-overlay'); const modal = document.getElementById('modal'); if (modalAutoCloseTimer) { clearTimeout(modalAutoCloseTimer); modalAutoCloseTimer = null } overlay.classList.remove('active'); modal.style.display = 'none'; } let busy = false let loadingStartTs = 0 function showLoadingModal(text = '正在加载,请稍候...') { const modal = document.getElementById('modal') const overlay = document.getElementById('modal-overlay') const modalTitle = document.getElementById('modal-title') const modalBody = document.getElementById('modal-body') const modalFooter = document.getElementById('modal-footer') if (modalAutoCloseTimer) { clearTimeout(modalAutoCloseTimer); modalAutoCloseTimer = null } modalTitle.textContent = '加载中' modalBody.innerHTML = `
${text}
` modalFooter.innerHTML = '' const statusNode = document.createElement('div') statusNode.className = 'modal-status' const actions = document.createElement('div') actions.className = 'modal-actions' modalFooter.appendChild(statusNode) modalFooter.appendChild(actions) const copyBtn = document.createElement('button'); copyBtn.className = 'btn'; copyBtn.textContent = '复制日志'; copyBtn.onclick = () => { try { const box = document.getElementById('loading-steps') const lines = box ? Array.from(box.children).map(n => String(n.textContent||'')).join('\n') : '' const s = lines || '' if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(s).then(()=>{ try { addLoadingStep('已复制日志到剪贴板') } catch {} }).catch(()=>{}) } else { const ta = document.createElement('textarea'); ta.value = s; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy') } catch {} ; document.body.removeChild(ta); try { addLoadingStep('已复制日志到剪贴板') } catch {} } } catch {} } const closeBtn = document.createElement('button'); closeBtn.className = 'btn btn-secondary'; closeBtn.textContent = '关闭'; closeBtn.onclick = () => { hideModal() } actions.appendChild(copyBtn) actions.appendChild(closeBtn) overlay.classList.add('active') modal.style.display = 'block' } function hideLoadingModal() { hideModal() } function addLoadingStep(text) { try { const box = document.getElementById('loading-steps') if (!box) return const delta = loadingStartTs ? (Date.now() - loadingStartTs) : 0 const item = document.createElement('div') item.textContent = (delta ? `[+${delta}ms] ` : '') + String(text||'') box.appendChild(item) box.scrollTop = box.scrollHeight } catch {} } async function runWithLoading(work, successText = '加载成功') { if (busy) { showModal('加载中', '当前正在加载,请稍候...'); return } busy = true loadingStartTs = Date.now() showLoadingModal() try { await work() hideLoadingModal() showModal('成功', successText, null, null, { autoClose: 3000 }) } catch (e) { hideLoadingModal() alert(String(e.message||e)) } finally { busy = false } } // 绑定模态框关闭事件 document.getElementById('modal-close').onclick = hideModal; document.getElementById('modal-overlay').onclick = (e) => { if (e.target === document.getElementById('modal-overlay')) { hideModal(); } }; // 移动端菜单功能 function initMobileMenu() { const menuBtn = document.getElementById('mobile-menu-btn'); const sidebar = document.querySelector('.sidebar'); if (menuBtn && sidebar) { menuBtn.addEventListener('click', () => { sidebar.classList.toggle('active'); }); // 点击内容区域关闭菜单 document.querySelector('.main-content').addEventListener('click', () => { if (window.innerWidth <= 900) { sidebar.classList.remove('active'); } }); } } // 窗口大小变化时调整菜单显示 window.addEventListener('resize', () => { const sidebar = document.querySelector('.sidebar'); const menuBtn = document.getElementById('mobile-menu-btn'); if (window.innerWidth > 900) { if (sidebar) sidebar.classList.remove('active'); if (menuBtn) menuBtn.style.display = 'none'; } else { if (menuBtn) menuBtn.style.display = 'block'; } }); // 初始化移动端菜单 initMobileMenu(); // 初始化侧边栏收起功能 initSidebarToggle(); // 侧边栏收起功能 function initSidebarToggle() { const sidebar = document.getElementById('sidebar'); const toggleBtn = document.getElementById('sidebar-toggle'); if (!sidebar || !toggleBtn) return; // 默认收起:本地无记录时设为收起 const stored = localStorage.getItem('sidebarCollapsed') const isCollapsed = (stored === 'true') || (stored === null) if (isCollapsed) { sidebar.classList.add('collapsed') if (typeof adjustSidebarWidth === 'function') adjustSidebarWidth() } if (stored === null) localStorage.setItem('sidebarCollapsed', 'true') toggleBtn.addEventListener('click', () => { sidebar.classList.toggle('collapsed'); const isNowCollapsed = sidebar.classList.contains('collapsed'); localStorage.setItem('sidebarCollapsed', isNowCollapsed); if (typeof adjustSidebarWidth === 'function') adjustSidebarWidth(); }); // 根据内容动态调整菜单宽度 function adjustSidebarWidth() { const navItems = document.querySelectorAll('.nav-item'); let maxWidth = 0; // 创建临时元素测量宽度 const tempElement = document.createElement('span'); tempElement.style.position = 'absolute'; tempElement.style.visibility = 'hidden'; tempElement.style.whiteSpace = 'nowrap'; document.body.appendChild(tempElement); if (sidebar.classList.contains('collapsed')) { // 收起状态:根据图标宽度调整 tempElement.style.fontSize = '18px'; navItems.forEach(item => { const iconElement = item.querySelector('.nav-icon'); if (iconElement) { tempElement.textContent = iconElement.textContent; const iconWidth = tempElement.offsetWidth; maxWidth = Math.max(maxWidth, iconWidth); } }); // 计算最终宽度:图标宽度 + 内边距 const finalWidth = Math.max(60, maxWidth + 32); sidebar.style.width = finalWidth + 'px'; } else { // 展开状态:根据图标+文本的总宽度调整 tempElement.style.fontSize = '14px'; tempElement.style.fontWeight = '500'; // 测量图标宽度(使用图标字体大小) let maxIconWidth = 0; tempElement.style.fontSize = '16px'; navItems.forEach(item => { const iconElement = item.querySelector('.nav-icon'); if (iconElement) { tempElement.textContent = iconElement.textContent; const iconWidth = tempElement.offsetWidth; maxIconWidth = Math.max(maxIconWidth, iconWidth); } }); // 测量文本宽度 let maxTextWidth = 0; tempElement.style.fontSize = '14px'; navItems.forEach(item => { const textElement = item.querySelector('.nav-text'); if (textElement) { tempElement.textContent = textElement.textContent; const textWidth = tempElement.offsetWidth; maxTextWidth = Math.max(maxTextWidth, textWidth); } }); // 计算最终宽度:图标宽度 + 文本宽度 + 间距 + 内边距 const totalWidth = maxIconWidth + maxTextWidth + 12 + 48; // 12px间距 + 48px内边距 const finalWidth = Math.max(200, Math.min(320, totalWidth)); sidebar.style.width = finalWidth + 'px'; } document.body.removeChild(tempElement); } // 初始调整和窗口大小变化时重新调整 adjustSidebarWidth(); window.addEventListener('resize', adjustSidebarWidth); // 监听字体加载完成 document.fonts.ready.then(adjustSidebarWidth); } // 更新模态框事件处理 const btnOpenSearch = el('btn-open-search') if (btnOpenSearch) { btnOpenSearch.onclick = () => { switchView('search') if (window.innerWidth <= 900) { const s = document.querySelector('.sidebar') if (s) s.classList.remove('active') } } } const btnOpenSettings = el('btn-open-settings') if (btnOpenSettings) { btnOpenSettings.onclick = () => { switchView('settings') if (window.innerWidth <= 900) { const s = document.querySelector('.sidebar') if (s) s.classList.remove('active') } try { loadSettingsTabs() } catch {} } } el('btn-search').onclick = async () => { await runWithLoading(async ()=>{ const q = el('search-q').value.trim() const res = await fetch(`${apiBase}/search?q=${encodeURIComponent(q)}`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() const list = data.results||[] const wrap = el('search-results'); wrap.innerHTML='' list.forEach(r=>{ const d=document.createElement('div'); d.className='event'; d.innerHTML=`[${r.type}] `+highlight(r.title||'', q.split(/[\s,]+/).filter(Boolean)); wrap.appendChild(d) }) }, '搜索完成') } let currentEditor = el('report-content') el('report-content').addEventListener('focus', ()=>{ currentEditor = el('report-content') }) const evContentNode = el('ev-content'); if (evContentNode) evContentNode.addEventListener('focus', ()=>{ currentEditor = el('ev-content') }) async function insertImageFile(file, target) { if (!file) return const r = new FileReader() await new Promise((resolve)=>{ r.onload = resolve; r.readAsDataURL(file) }) const img = document.createElement('img') img.src = r.result img.style.maxWidth = '100%' const editor = target || currentEditor if (!editor) return const sel = window.getSelection() if (sel && sel.rangeCount>0 && editor.contains(sel.anchorNode)) sel.getRangeAt(0).insertNode(img) else editor.appendChild(img) } function bindPasteAndDrop(node) { if (!node) return node.addEventListener('paste', async (e) => { try { const items = e.clipboardData && e.clipboardData.items ? Array.from(e.clipboardData.items) : [] const imgs = items.filter(it => String(it.type||'').startsWith('image/')) if (imgs.length === 0) return e.preventDefault() for (const it of imgs) { const f = it.getAsFile(); await insertImageFile(f, node) } } catch {} }) node.addEventListener('drop', async (e) => { try { const files = e.dataTransfer && e.dataTransfer.files ? Array.from(e.dataTransfer.files) : [] const imgs = files.filter(f => String(f.type||'').startsWith('image/')) if (imgs.length === 0) return e.preventDefault() for (const f of imgs) await insertImageFile(f, node) } catch {} }) } bindPasteAndDrop(el('report-content')) function applyCmd(cmd, evt) { if (!currentEditor) return const sel = window.getSelection() const inEditor = !!(sel && sel.rangeCount && currentEditor.contains(sel.anchorNode)) if (!inEditor) currentEditor.focus() if (cmd==='bold' || cmd==='italic' || cmd==='underline') document.execCommand(cmd) else if (cmd==='color' && evt && evt.value) document.execCommand('foreColor', false, evt.value) else if (cmd==='font-size' && evt && evt.value) { const px = parseInt(String(evt.value), 10) if (!Number.isFinite(px) || px<=0) return const s = window.getSelection() if (!s || s.rangeCount===0) return const r = s.getRangeAt(0) if (!currentEditor.contains(r.commonAncestorContainer)) return const span = document.createElement('span') span.style.fontSize = px + 'px' if (r.collapsed) { span.textContent = '' r.insertNode(span) const nr = document.createRange() nr.setStart(span, 0) nr.setEnd(span, 0) s.removeAllRanges() s.addRange(nr) } else { const frag = r.extractContents() span.appendChild(frag) r.insertNode(span) const nr = document.createRange() nr.selectNodeContents(span) s.removeAllRanges() s.addRange(nr) } } else if (cmd==='h1') document.execCommand('formatBlock', false, 'h1') else if (cmd==='h2') document.execCommand('formatBlock', false, 'h2') else if (cmd==='ul') document.execCommand('insertUnorderedList') else if (cmd==='ol') document.execCommand('insertOrderedList') else if (cmd==='quote') document.execCommand('formatBlock', false, 'blockquote') else if (cmd==='code') document.execCommand('formatBlock', false, 'pre') else if (cmd==='link') { const url = prompt('输入链接地址:') if (url) document.execCommand('createLink', false, url) } else if (cmd==='img') { const f = document.getElementById('img-file').files[0] if (!f) return alert('请选择图片文件') const r = new FileReader() r.onload = () => { const img = document.createElement('img') img.src = r.result img.style.maxWidth = '100%' const sel = window.getSelection() if (sel && sel.rangeCount>0) sel.getRangeAt(0).insertNode(img) else currentEditor.appendChild(img) } r.readAsDataURL(f) } else if (cmd==='table') { const rows = parseInt(prompt('行数:','3')||'3',10) const cols = parseInt(prompt('列数:','3')||'3',10) if (rows>0 && cols>0) { const table = document.createElement('table'); table.style.borderCollapse='collapse'; table.style.width='100%' for (let r=0;r0) { const r = s.getRangeAt(0) const t = document.createTextNode(val) r.insertNode(t) r.setStartAfter(t) r.setEndAfter(t) s.removeAllRanges() s.addRange(r) } else { currentEditor.appendChild(document.createTextNode(val)) } } } else if (cmd==='color' && evt && evt.value) document.execCommand('foreColor', false, evt.value) else if (cmd==='font-size' && evt && evt.value) { const px = parseInt(String(evt.value), 10) if (!Number.isFinite(px) || px<=0) return const s = window.getSelection() if (!s || s.rangeCount===0) return const r = s.getRangeAt(0) if (!currentEditor.contains(r.commonAncestorContainer)) return const span = document.createElement('span') span.style.fontSize = px + 'px' if (r.collapsed) { span.textContent = '' r.insertNode(span) const nr = document.createRange() nr.setStart(span, 0) nr.setEnd(span, 0) s.removeAllRanges() s.addRange(nr) } else { const frag = r.extractContents() span.appendChild(frag) r.insertNode(span) const nr = document.createRange() nr.selectNodeContents(span) s.removeAllRanges() s.addRange(nr) } } } // 绑定工具栏按钮点击事件 const toolbar = document.querySelector('.toolbar') if (toolbar) { toolbar.querySelectorAll('button[data-cmd]').forEach(b => { b.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); applyCmd(b.dataset.cmd, e) }) }) } const emojiPanel = document.getElementById('emoji-panel') const btnEmoji = document.getElementById('btn-emoji') if (emojiPanel) { const emojiMap = { '常用': ['😀','😁','😂','🤣','😊','😉','🙂','🙃','🤔','😅','😆','😇','🥳','🤪','😎','😍','😘','😏','😒','😭','😤','😡','😴','🤗','🤭','🤯','😕','😟','😔','😞','😮','😲','😐','😑'], '手势': ['👍','👎','👏','🙌','🙏','👌','✌️','🤞','🤟','👊','🤝','👉','👈','👆','👇','☝️','✋','🤚','🖐','🖖','✍️'], '符号': ['✅','❌','⚠️','ℹ️','❗','❕','❓','❔','🚫','♻️','✔️','✖️','➕','➖','➗','🔁','🔂','🔄','🔃','🔺','🔻','🔸','🔹','🔶','🔷','⬆️','⬇️','⬅️','➡️','⏹️','⏺️','⏯️','⏭️','⏮️','⭐','🌟','✨','💡','📍','📌'], '办公': ['📅','📆','🗓️','📝','✏️','✒️','🖊️','🖋️','📎','🗂️','📁','📂','🗃️','🗄️','📤','📥','📧','✉️','📨','📩','📢','📣','💬','🗨️','🗯️','🔔','🔕','🔒','🔑','🔧','🛠️'], '时间': ['⏰','⌚','⏱️','⏲️','🕰️','🕛','🕐','🕑','🕒','🕓','🕔','🕕','🕖','🕗','🕘','🕙','🕚','📅','🗓️'], '交通': ['🚀','🚗','🚕','🚙','🚌','🚎','🏎️','🚓','🚑','🚒','🚐','🚚','🚛','🚜','✈️','🛫','🛬','🛩️','🛳️','⛵','🛥️'], '食物': ['🍎','🍊','🍌','🍉','🍇','🍓','🍒','🍍','🥭','🥝','🍅','🥑','🥦','🥕','🌽','🍞','🍔','🍟','🍕','🌭','🍜','🍣','🍤','🍪','🍩','🍰','🍫'], '自然': ['🌞','🌝','🌚','🌧️','⛈️','🌩️','🌨️','❄️','☃️','🌪️','🌈','🔥','✨','🌟','⭐','🌙','☁️','🌤️','🌥️','🌦️','🌫️'] } const tabs = emojiPanel.querySelector('.emoji-tabs') const listWrap = document.getElementById('emoji-list') const cats = Object.keys(emojiMap) tabs.innerHTML = '' cats.forEach((name, idx) => { const tb = document.createElement('button') tb.type = 'button' tb.textContent = name if (idx===0) tb.classList.add('active') tb.dataset.cat = name tb.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); tabs.querySelectorAll('button').forEach(x => x.classList.remove('active')) tb.classList.add('active') renderEmoji(name) }) tabs.appendChild(tb) }) function renderEmoji(cat) { listWrap.innerHTML = '' const arr = emojiMap[cat] || [] arr.forEach(ch => { const b = document.createElement('button') b.type = 'button' b.textContent = ch b.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); applyCmd('emoji', { value: ch }); emojiPanel.classList.add('hidden') }) listWrap.appendChild(b) }) } renderEmoji(cats[0]) } if (btnEmoji && emojiPanel) { btnEmoji.addEventListener('mousedown', (e) => { e.preventDefault() e.stopPropagation() if (emojiPanel.classList.contains('hidden')) emojiPanel.classList.remove('hidden') else emojiPanel.classList.add('hidden') }) document.addEventListener('mousedown', (e) => { const t = e.target if (emojiPanel && !emojiPanel.contains(t) && t !== btnEmoji) emojiPanel.classList.add('hidden') }) } // 图片按钮特殊处理 document.querySelector('[data-cmd="img"]').addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); document.getElementById('img-file').click(); }) // 图片文件选择处理 document.getElementById('img-file').addEventListener('change', function(e) { const file = e.target.files[0]; if (file) { applyCmd('img'); } }); const colorInput = document.getElementById('font-color') if (colorInput) colorInput.addEventListener('input', function(e){ applyCmd('color', { value: e.target.value }) }) const btnColor = document.getElementById('btn-font-color') if (btnColor && colorInput) btnColor.addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); colorInput.click() }) const sizeSelect = document.getElementById('font-size') if (sizeSelect) sizeSelect.addEventListener('change', function(e){ applyCmd('font-size', { value: e.target.value }) }) window.addEventListener('keydown', (e)=>{ if ((e.ctrlKey || e.metaKey) && (e.key==='s' || e.key==='S')) { e.preventDefault(); saveReport() } }) async function initDebug() { try { const res = await fetch('/api/flags', { credentials:'same-origin', cache:'no-store' }) const data = await res.json() if (data && data.debug && data.debug.weekly) { const sec = document.getElementById('weekly-debug'); if (sec) sec.style.display='' } } catch {} const out = document.getElementById('wd-output') const refresh = async () => { try { const r = await fetch(`${apiBase}/logs`, { credentials:'same-origin', cache:'no-store' }) const j = await r.json() out.textContent = (j.lines||[]).join('\n') } catch { out.textContent = '读取失败' } } const clear = async () => { try { await fetch(`${apiBase}/logs/clear`, { method:'POST', credentials:'same-origin', cache:'no-store' }) await refresh() } catch {} } const copy = async () => { try { await navigator.clipboard.writeText(out.textContent||''); alert('已复制') } catch {} } const br = id => document.getElementById(id) const bind = (id, fn) => { const b = br(id); if (b) b.onclick = fn } bind('wd-refresh', refresh) bind('wd-clear', clear) bind('wd-copy', copy) } initDebug() function autoGenerateSummary() { const html = $content.innerHTML || '' const temp = document.createElement('div') temp.innerHTML = html const pickText = (sel, max) => Array.from(temp.querySelectorAll(sel)).map(x=>String(x.textContent||'').trim()).filter(Boolean).slice(0, max) const heads = pickText('h1,h2,h3', 3) const bullets = pickText('li', 6) const paraText = Array.from(temp.querySelectorAll('p,div')).map(x=>String(x.textContent||'').trim()).filter(Boolean).join(' ') const sentences = String(paraText).split(/[。!?!?\n]/).map(s=>s.trim()).filter(s=>s.length>=8).slice(0, 3) const parts = [] if (heads.length) parts.push('标题:' + heads.join(' / ')) if (bullets.length) parts.push('要点:' + bullets.join(';')) if (sentences.length) parts.push('摘要:' + sentences.join('。')) let out = parts.join('\n') if (!out) out = (paraText || '').slice(0, 300) $summary.value = out } const autoBtn = document.getElementById('btn-auto-summary'); if (autoBtn) autoBtn.onclick = autoGenerateSummary el('btn-save-settings').onclick = async () => { const email = { enabled: el('email-enabled').checked } await fetch('/api/weekly/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ email }), credentials:'same-origin', cache:'no-store' }) showModal('操作成功', '已保存') } async function loadSettingsTabs() { const tabs = document.querySelectorAll('.settings-tabs .tab-btn') const contents = document.querySelectorAll('.tab-content') tabs.forEach(btn => { btn.addEventListener('click', () => { const name = btn.dataset.tab tabs.forEach(b => b.classList.remove('active')) contents.forEach(c => c.classList.remove('active')) btn.classList.add('active') const c = document.getElementById(`tab-${name}`) if (c) c.classList.add('active') if (name === 'ai') loadAiSettings() }) }) } async function loadAiSettings() { try { const res = await fetch('/api/weekly/settings', { credentials:'same-origin', cache:'no-store' }) const data = await res.json() const ai = data.weekly_ai || {} const defs = data.weekly_ai_defaults || {} const prompt = String((ai.system_prompt || defs.system_prompt || '')) const model = String((ai.model || defs.model || '')) const temp = (ai.hasOwnProperty('temperature') ? ai.temperature : defs.temperature) const t = document.getElementById('ai-system-prompt') const m = document.getElementById('ai-model') const n = document.getElementById('ai-temperature') if (t) t.value = prompt if (m) m.value = model if (n) n.value = (typeof temp === 'number' || typeof temp === 'string') ? String(temp) : '' } catch {} } const btnSaveAi = document.getElementById('btn-save-ai') if (btnSaveAi) btnSaveAi.onclick = async () => { const prompt = document.getElementById('ai-system-prompt') ? document.getElementById('ai-system-prompt').value : '' const model = document.getElementById('ai-model') ? document.getElementById('ai-model').value : '' const temperature = parseFloat(document.getElementById('ai-temperature') ? document.getElementById('ai-temperature').value : '') const weekly_ai = {} if (prompt) weekly_ai.system_prompt = prompt if (model) weekly_ai.model = model if (Number.isFinite(temperature)) weekly_ai.temperature = temperature await fetch('/api/weekly/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ weekly_ai }), credentials:'same-origin', cache:'no-store' }) showModal('操作成功', 'AI设置已保存') } const btnResetAi = document.getElementById('btn-reset-ai') if (btnResetAi) btnResetAi.onclick = async () => { try { const res = await fetch('/api/weekly/settings', { credentials:'same-origin', cache:'no-store' }) const data = await res.json() const defs = data.weekly_ai_defaults || {} const t = document.getElementById('ai-system-prompt') const m = document.getElementById('ai-model') const n = document.getElementById('ai-temperature') if (t) t.value = String(defs.system_prompt || '') if (m) m.value = String(defs.model || '') if (n) n.value = (defs.hasOwnProperty('temperature') ? String(defs.temperature) : '') const weekly_ai = {} if (defs.system_prompt) weekly_ai.system_prompt = defs.system_prompt if (defs.model) weekly_ai.model = defs.model if (defs.hasOwnProperty('temperature')) weekly_ai.temperature = defs.temperature await fetch('/api/weekly/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ weekly_ai }), credentials:'same-origin', cache:'no-store' }) showModal('操作成功', '已重置为默认') } catch { showModal('提示', '重置失败') } } el('btn-backup').onclick = async () => { const res = await fetch(`${apiBase}/backup`, { method:'POST', credentials:'same-origin', cache:'no-store' }) const data = await res.json(); if (data.ok) { const a=el('backup-link'); a.href = data.path; a.style.display=''; a.textContent='下载备份'; showModal('操作成功','备份已生成'); } else showModal('提示','失败') } el('btn-restore').onclick = async () => { const f = el('restore-file').files[0] if (!f) return const buf = await f.arrayBuffer() const res = await fetch(`${apiBase}/restore`, { method:'POST', headers:{'Content-Type':'application/octet-stream'}, body: new Uint8Array(buf), credentials:'same-origin', cache:'no-store' }) const data = await res.json(); showModal('操作结果', data.ok?'已恢复':'失败') } el('btn-refresh-reminders').onclick = async () => { const res = await fetch(`${apiBase}/reminders`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() const wrap = el('reminders'); wrap.innerHTML='' const due = (data.dueTomorrow||[]).map(d=>`[明日] [${d.week_id}] ${d.title} @ ${d.deadline}`) const od = (data.overdue||[]).map(d=>`[逾期] [${d.week_id}] ${d.title} @ ${d.deadline}`) const all = due.concat(od) all.forEach(s=>{ const div=document.createElement('div'); div.className='event'; div.textContent=s; wrap.appendChild(div) }) showModal('成功','提醒已刷新') } el('btn-load-versions').onclick = async () => { if (!current.report || !current.report.id) return const res = await fetch(`${apiBase}/versions?subject_type=report&subject_id=${current.report.id}`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() const rows = data.rows||[] const a = el('ver-a'), b = el('ver-b'); a.innerHTML=''; b.innerHTML='' rows.forEach(v=>{ const o=document.createElement('option'); o.value=String(v.id); o.textContent=`${v.id} ${v.created_at}`; a.appendChild(o) }) rows.forEach(v=>{ const o=document.createElement('option'); o.value=String(v.id); o.textContent=`${v.id} ${v.created_at}`; b.appendChild(o) }) showModal('成功','版本列表已加载') } el('btn-diff').onclick = async () => { const a = el('ver-a').value, b = el('ver-b').value if (!a || !b) return const res = await fetch(`${apiBase}/version/diff?from=${a}&to=${b}`, { credentials:'same-origin', cache:'no-store' }) const data = await res.json() const wrap = el('diff-output'); wrap.innerHTML='' const changes = data.changes||[] changes.forEach(c=>{ const div=document.createElement('div'); div.className='event'; div.innerHTML=`${c.field}
旧:${c.from||''}
新:${c.to||''}`; wrap.appendChild(div) }) showModal('成功','版本对比已生成') } async function applyQuickFilters() { const dim = document.getElementById('quick-time').value const rel = document.getElementById('quick-rel').value const range = {} if (dim==='week') { const w = $weeks.value; range.start = w; range.end = w } else { const w = $weeks.value const m = w.slice(0,4)+'-'+w.slice(6,8) // rough year-week if (dim==='month') { range.start = w; range.end = w } // placeholder: reuse current week if (dim==='quarter') { range.start = w; range.end = w } } const body = { range, status: [], mime: [] } const res = await fetch(`${apiBase}/search/advanced`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body), credentials:'same-origin', cache:'no-store' }) const data = await res.json() const rows = data.rows||[] let filtered = rows if (rel==='has_parent' || rel==='has_child') { // need relation data; keep simple by checking existence const ids = filtered.map(r=>r.id) const relResp = await Promise.all(ids.map(id=>fetch(`${apiBase}/relations?event_id=${id}`, { credentials:'same-origin', cache:'no-store' }))) const relData = await Promise.all(relResp.map(r=>r.json())) filtered = filtered.filter((r,i)=>{ const a = relData[i].rows||[] const has = a.some(x=> (rel==='has_parent' ? x.target_event_id===r.id : x.event_id===r.id)) return has }) } $events.innerHTML='' filtered.forEach(ev=>renderEvent(ev)) for (const ev of filtered) { const reviews = document.querySelector(`.reviews[data-event-id="${ev.id}"]`) const atts = document.querySelector(`.attachments[data-event-id="${ev.id}"]`) if (reviews) fetchAndRenderReviews(ev.id, reviews) if (atts) fetchAndRenderAttachments(ev.id, atts) } } const quickBtn = document.getElementById('btn-apply-quick'); if (quickBtn) quickBtn.onclick = async () => { await runWithLoading(applyQuickFilters, '筛选加载成功') } const btnRefreshOverview = document.getElementById('btn-refresh-overview'); if (btnRefreshOverview) { btnRefreshOverview.onclick = async (e) => { e.stopPropagation(); const icon = btnRefreshOverview; icon.style.transition = 'transform 0.5s'; icon.style.transform = 'rotate(360deg)'; setTimeout(() => { icon.style.transition = 'none'; icon.style.transform = ''; }, 500); await runWithLoading(() => initOverview(), '概览数据已刷新'); } } async function pushCalendar(event_id) { if (busy) { showModal('请稍候', '正在处理上一个请求...'); return } busy = true loadingStartTs = Date.now() showLoadingModal('正在推送日历,请耐心等待(约30秒)...') try { addLoadingStep('正在初始化推送请求...') try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.push.start', data:{ event_id } }), credentials:'same-origin', cache:'no-store' }) } catch {} addLoadingStep('正在连接服务器并转发至日历接口...') const res = await fetch(`${apiBase}/calendar/push?event_id=${event_id}`, { method:'POST', credentials:'same-origin', cache:'no-store' }) addLoadingStep('服务器已响应,正在解析结果...') const data = await res.json() try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.push.response', data:{ event_id, ok: !!(data && data.ok), error: data && data.error, endpoint: data && data.endpoint, method: data && data.method, status: data && data.status } }), credentials:'same-origin', cache:'no-store' }) } catch {} hideLoadingModal() const esc = s => String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) if (data && data.ok) { const p = data.payload || {} const html = `
目标地址:${esc(data.endpoint||'')}
请求方法:${esc(data.method||'')}
推送标题:${esc(p.title||'')}
开始时间:${esc(p.start||'')}
结束时间:${esc(p.end||'')}
提醒时间:${esc(p.remindAt||'')}
周编号:${esc(p.week_id||'')}
状态码:${esc(String(data.status||''))}
返回片段:
${esc(data.body||'')}
` showModal('日历推送结果', html) } else if (data && data.error==='no_deadline') { showModal('提示','未设置截止时间,无法推送') } else if (data && data.error==='not_required') { showModal('提示','未勾选“需邮件和日历等推送”,未推送') } else if (data && data.error==='disabled') { showModal('提示','未启用日历推送,请先在配置中开启') } else if (data && data.error==='no_endpoint') { showModal('提示','未配置日历推送地址') } else { const ep = data && data.endpoint ? data.endpoint : '' const status = data && data.status ? `状态码:${data.status}` : '' showModal('推送失败', `目标地址:${esc(ep)}
${esc(status)}
错误信息:${esc(data && data.error || '未知错误')}`) } } catch (e) { hideLoadingModal() try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.push.error', data:{ event_id, error: String((e && e.message) || e) } }), credentials:'same-origin', cache:'no-store' }) } catch {} showModal('提示','推送失败: ' + String(e.message||e)) } finally { busy = false } } // Settings Tab Switching Logic function switchSettingsTab(tabName) { // Update tab buttons document.querySelectorAll('.settings-tabs .tab-btn').forEach(btn => { if (btn.dataset.tab === tabName) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); // Update tab content document.querySelectorAll('.tab-content').forEach(content => { if (content.id === `tab-${tabName}`) { content.classList.add('active'); } else { content.classList.remove('active'); } }); } // Bind click events to tab buttons const tabBtns = document.querySelectorAll('.settings-tabs .tab-btn'); if (tabBtns.length > 0) { tabBtns.forEach(btn => { btn.addEventListener('click', () => { switchSettingsTab(btn.dataset.tab); }); }); } })()