1944 lines
91 KiB
JavaScript
1944 lines
91 KiB
JavaScript
;(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 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 `
|
||
<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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||
});
|
||
});
|
||
}
|
||
})()
|