;(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 = `
`
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);
});
});
}
})()