Files
Toolbox/public/tools/weekly/weekly.js
T
yangxiangyuan a6f763ffd8 feat: 重构认证流程,升级工具功能并优化后端配置
- 修复token泄露风险:改用X-Local-Creds-Token请求头传递token,从URL Hash读取set_token避免日志记录
- 重构全站认证逻辑:提取公用工具函数统一各服务的认证校验流程
- 升级投资账本工具:支持本地上传CSV和粘贴文本,移除硬编码路径,适配新参数格式
- 新增403/404/500标准错误响应文件,统一API错误返回格式
- 优化后端配置:关闭ETag,重构CORS中间件,新增全局错误捕获,限制敏感文件访问
- 优化web zen box的Service Worker,支持音频分片请求与断点续传
- 清理冗余前端代码,调整页面格式与权限控制
2026-08-01 21:50:05 +08:00

1947 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
;(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 [
'<h2 style="color:#0000FF">1、本周核心重点事项(置顶)</h2>',
'<p>①置顶优先级,避免杂事分散精力</p>',
'<p>②本周 1-3 件最高优先级事(工作 / 家庭各列,如公司项目节点、孩子升学事宜)</p>',
'<p>③必做底线项(如固定会议、家庭日常、记账 / 复盘打卡)</p>',
'<p><br></p>',
'<h2 style="color:#0000FF">2、上周复盘(工作)</h2>',
'<p>-----</p>',
'<p>①公司工作完成项(标注是否达标、核心成果)</p>',
'<p>②未完成项 + 原因(客观 / 主观,如时间不足、需求变更)</p>',
'<p>③规则事务完成情况(重点记未完成的关键项)</p>',
'<p><br></p>',
'<h2 style="color:#0000FF">3、上周复盘(个人 + 家庭)</h2>',
'<p>-----</p>',
'<p>①个人 / 家庭事务完成情况(重点记未完成的关键项)</p>',
'<p><br></p>',
'<h2 style="color:#0000FF">4、公司工作(计划)</h2>',
'<p>-----</p>',
'<p>①核心任务(按重要 / 紧急排序,标注截止时间、协作人)</p>',
'<p>②常规工作(日常运维、对接、汇报等固定事项)</p>',
'<p>③待协调 / 需支持项(如需要同事配合、领导审批的内容)</p>',
'<p><br></p>',
'<h2 style="color:#0000FF">5、个人和家庭 事务(计划,含理财)</h2>',
'<p>-----</p>',
'<p>①自我提升(学习、运动、习惯打卡,如读 1 章书、运动 3 次)</p>',
'<p>②个人琐事(办事、缴费、社交等,标注时间节点)</p>',
'<p>③投资理财(盯盘节点、条件单调整、复盘上周收益 / 操作)</p>',
'<p>④家人相关(孩子学习 / 活动、老人照料、家庭聚餐等)</p>',
'<p>⑤家庭琐事(家务、采购、物业对接等,可标注分工)</p>',
'<p>⑥家庭规划(短期家庭事项,如周末出游、家电维修)</p>',
'<p><br></p>',
'<h2 style="color:#0000FF">6、待解决问题 / 待协调项</h2>',
'<p>-----</p>',
'<p>①工作中可能遇到的问题(如项目延期风险、资源不足)</p>',
'<p>②个人 / 家庭的待处理问题(如时间冲突、资金安排)</p>',
'<p>③初步应对思路(简单标注,避免临时慌乱)</p>',
'<p><br></p>',
'<h2 style="color:#0000FF">7、本周小目标 / 打卡项(轻量化激励,易落地)</h2>',
'<p>①工作小目标(如高效完成 1 个核心任务、减少无效沟通)</p>',
'<p>②个人 / 家庭小目标(如坚持记账、周末全家运动 1 次)</p>'
].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<listAll.length;i++) { const w=listAll[i]; if (String(w.start)<=today && today<=String(w.end)) { idx=i; break } }
const yearStart = `${year}-01-01`
let nextEnd = null
if (idx>=0 && idx+1<listAll.length) nextEnd = String(listAll[idx+1].end)
else if (idx>=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<listAll.length && listAll[idx+1]) ? listAll[idx+1].week_id : ''
let hasCurrent = false
display.forEach(w => {
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(/<br\s*\/?>/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 = `<span class="event-meta-icon">⏰</span>截止时间:${formatCNDeadline(ev.deadline)}`;
meta.appendChild(deadlineItem);
}
const pushItem = document.createElement('div'); pushItem.className = 'event-meta-item';
pushItem.innerHTML = `<span class="event-meta-icon">📧</span>推送:${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 = `
<option value="doing">▶️进行中</option>
<option value="done">✔️完成</option>
<option value="pause">⏸️暂停</option>
<option value="new">📅本周新增</option>
<option value="close">❌关闭</option>
<option value="not">👋未做</option>
<option value="ok">👌成功</option>
<option value="fail">⛔失败</option>
`
mb.innerHTML = `
<div class="form">
<input id="modal-ev-title" placeholder="事件标题" />
<div id="modal-ev-content" class="editor" contenteditable="true" placeholder="事件内容"></div>
<select id="modal-ev-status">${options}</select>
<input id="modal-ev-deadline" type="datetime-local" />
<label><input id="modal-ev-push" type="checkbox" />定时自动推送到邮件和日历</label>
</div>
`
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<total;i++) {
const start = i * chunkSize
const end = Math.min(size, start + chunkSize)
const blob = file.slice(start, end)
const buf = new Uint8Array(await blob.arrayBuffer())
const url = `${apiBase}/attachments/chunk?event_id=${event_id}&filename=${encodeURIComponent(file.name)}&mime=${encodeURIComponent(file.type)}&index=${i}&total=${total}&upload_id=${encodeURIComponent(uploadId)}`
const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/octet-stream' }, body: buf, credentials:'same-origin', cache:'no-store' })
const data = await res.json(); if (!data.ok) { if (progText) progText.textContent='分片上传失败'; alert('分片上传失败'); return }
uploadId = data.upload_id || uploadId
const pct = Math.round(((i+1) / total) * 100)
if (progBar) progBar.style.width = pct + '%'
if (progText) progText.textContent = `正在上传 ${pct}%`
}
if (progText) progText.textContent = '上传完成'
showModal('操作成功', `附件已上传:${file.name}`)
}
async function uploadAttachment(event_id, file) {
if (!file) return
const size = file.size
const chunkSize = 2 * 1024 * 1024
if (size <= chunkSize) {
const buf = await file.arrayBuffer()
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) { alert('上传失败'); return }
} else {
const total = Math.ceil(size / chunkSize)
let uploadId = ''
for (let i=0;i<total;i++) {
const start = i * chunkSize
const end = Math.min(size, start + chunkSize)
const blob = file.slice(start, end)
const buf = new Uint8Array(await blob.arrayBuffer())
const url = `${apiBase}/attachments/chunk?event_id=${event_id}&filename=${encodeURIComponent(file.name)}&mime=${encodeURIComponent(file.type)}&index=${i}&total=${total}&upload_id=${encodeURIComponent(uploadId)}`
const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/octet-stream' }, body: buf, credentials:'same-origin', cache:'no-store' })
const data = await res.json(); if (!data.ok) { alert('分片上传失败'); return }
uploadId = data.upload_id || uploadId
}
}
showModal('操作成功', '附件已上传')
}
async function autoSummarize() {
try { fetch(`${apiBase}/logs/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tag:'weekly.ui.summary.click', data:{ ts: Date.now() } }), credentials:'same-origin', cache:'no-store' }) } catch {}
if (busy) { showModal('加载中', '当前正在加载,请稍候...'); return }
busy = true
loadingStartTs = Date.now()
showLoadingModal('开始生成摘要...')
addLoadingStep('点击事件已触发,准备采集正文与发起请求')
const text = $content.innerText.trim()
if (!text) {
addLoadingStep('正文为空:请先填写周报内容')
busy = false
return
}
try {
addLoadingStep(`收集正文: length=${text.length}, preview="${text.slice(0,120)}"`)
const url = `${apiBase}/summarize`
addLoadingStep(`目标接口: ${url}`)
const bodyObj = { text }
let bodyStr = ''
try { bodyStr = JSON.stringify(bodyObj) } catch {}
addLoadingStep(`请求体: length=${bodyStr.length}, preview="${bodyStr.slice(0,200)}"`)
let res = null, data = null
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: bodyStr,
credentials: 'same-origin',
cache: 'no-store'
})
addLoadingStep(`HTTP状态: ${res.status} ${res.statusText}`)
const ct = String(res.headers.get('content-type')||'')
addLoadingStep(`Content-Type: ${ct}`)
if (ct.includes('application/json')) {
try {
data = await res.clone().json()
const preview = JSON.stringify(data).slice(0,300)
addLoadingStep(`响应JSON: preview="${preview}"`)
} catch (e) {
addLoadingStep(`JSON解析失败: ${String(e&&e.message||e)}`)
const txt = await res.clone().text().catch(()=> '')
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 res = await fetch(apiBase + '/auth/local_creds', {
headers: token ? { 'X-Local-Creds-Token': token } : {},
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 `
<div class="chart-col">
<div class="chart-val">${t.total}</div>
<div class="chart-bar-group">
<div class="chart-bar total" style="height:${hTotal}%" title="总计: ${t.total}"></div>
<div class="chart-bar completed" style="height:${hDone}%" title="完成: ${t.completed}"></div>
</div>
<div class="chart-label">${t.week_id.slice(5)}</div>
</div>
`
}).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 = '<div class="heatmap-grid">';
// 显示最近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 += `<div class="heatmap-cell" data-level="${level}" title="${ymd}: ${count} 次变动"></div>`;
}
html += '</div><div class="heatmap-legend">Less <span class="heatmap-cell"></span><span class="heatmap-cell" data-level="2"></span><span class="heatmap-cell" data-level="4"></span> More</div>';
heatEl.innerHTML = html;
}
// 更新最近活动
const activities = data.recentActivities || [];
const activitiesContainer = document.getElementById('recent-activities');
if (activities.length > 0) {
activitiesContainer.innerHTML = activities.map(activity => `
<div class="activity-item">
<div class="activity-type">${activity.type}</div>
<div class="activity-content">${activity.content}</div>
<div class="activity-time">${String(activity.time).slice(5, 16).replace('T',' ')}</div>
</div>
`).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 = `<div class="loading">${text}</div><div id="loading-steps" class="loading-steps" style="margin-top:8px;font-size:13px;line-height:1.6;color:#374151;max-height:300px;overflow-y:auto;background:#f9fafb;padding:8px;border-radius:4px;border:1px solid #e5e7eb"></div>`
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;r<rows;r++) {
const tr = document.createElement('tr')
for (let c=0;c<cols;c++) { const td=document.createElement('td'); td.textContent=''; td.style.border='1px solid #ddd'; td.style.padding='6px'; tr.appendChild(td) }
table.appendChild(tr)
}
currentEditor.appendChild(table)
}
} else if (cmd==='undo') document.execCommand('undo')
else if (cmd==='redo') document.execCommand('redo')
else if (cmd==='template') {
insertWeeklyTemplate({ replace: false })
} else if (cmd==='clear') document.execCommand('removeFormat')
else if (cmd==='emoji' && evt && evt.value) {
const val = String(evt.value || '')
const supported = typeof document.queryCommandSupported==='function' ? document.queryCommandSupported('insertText') : false
if (supported) document.execCommand('insertText', false, val)
else {
const s = window.getSelection()
if (s && s.rangeCount>0) {
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=`<b>${c.field}</b><br/>旧:${c.from||''}<br/>新:${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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
if (data && data.ok) {
const p = data.payload || {}
const html = `
<div class="form">
<div>目标地址:${esc(data.endpoint||'')}</div>
<div>请求方法:${esc(data.method||'')}</div>
<div>推送标题:${esc(p.title||'')}</div>
<div>开始时间:${esc(p.start||'')}</div>
<div>结束时间:${esc(p.end||'')}</div>
<div>提醒时间:${esc(p.remindAt||'')}</div>
<div>周编号:${esc(p.week_id||'')}</div>
<div>状态码:${esc(String(data.status||''))}</div>
<div>返回片段:</div>
<div class="editor" style="min-height:80px">${esc(data.body||'')}</div>
</div>
`
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)}<br/>${esc(status)}<br/>错误信息:${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);
});
});
}
})()