690 lines
29 KiB
JavaScript
690 lines
29 KiB
JavaScript
// ===== 从 index.php 迁移的脚本(中文状态版) =====
|
||
const Statuses = ["待做","进行","完成","异常"]; // 四种状态
|
||
let Projects = []; // 全量项目数据
|
||
let CurrentProjectId = null;
|
||
const ShowStep = 15; // 每列初始显示数量
|
||
let ShowLimits = { '待做': ShowStep, '进行': ShowStep, '完成': ShowStep, '异常': ShowStep };
|
||
let Filters = { status: '全部', search: '', month: '' }; // month: YYYY-MM
|
||
|
||
// 获取项目的年月键(优先 created_at,其次 updated_at)
|
||
function projectMonthKey(p) {
|
||
return monthKeyFromDateStr(p?.created_at || p?.updated_at || '') || null;
|
||
}
|
||
|
||
const api = async (url, options = {}) => {
|
||
const res = await fetch(url, options);
|
||
const ct = res.headers.get('Content-Type') || '';
|
||
if (ct.includes('application/json')) {
|
||
try {
|
||
const data = await res.json();
|
||
if (data && data.need_login) {
|
||
// 统一跳回登录页
|
||
location.href = 'index.php';
|
||
throw new Error('未登录');
|
||
}
|
||
return data;
|
||
} catch (e) {
|
||
// JSON 解析异常时直接返回空
|
||
return null;
|
||
}
|
||
}
|
||
return res.text();
|
||
};
|
||
|
||
// 退出登录
|
||
async function logout() {
|
||
try {
|
||
const data = await api('api.php?action=logout', { method: 'POST' });
|
||
// 清理本地标记
|
||
try { localStorage.removeItem('auth_ok'); } catch (e) {}
|
||
// 返回登录页
|
||
location.href = 'index.php';
|
||
} catch (e) {
|
||
// 即便异常也尝试跳转
|
||
location.href = 'index.php';
|
||
}
|
||
}
|
||
|
||
async function loadProjects() {
|
||
const data = await api('api.php?action=list_projects');
|
||
Projects = Array.isArray(data?.projects) ? data.projects : [];
|
||
// 每次加载数据时重置显示数量(避免越来越多)
|
||
ShowLimits = { '待做': ShowStep, '进行': ShowStep, '完成': ShowStep, '异常': ShowStep };
|
||
renderBoard();
|
||
renderMonthlyChart();
|
||
}
|
||
|
||
function renderBoard() {
|
||
const board = document.getElementById('statusBoard');
|
||
board.innerHTML = '';
|
||
const statusesToRender = Filters.status === '全部' ? Statuses : [Filters.status];
|
||
const q = (Filters.search || '').trim().toLowerCase();
|
||
statusesToRender.forEach(st => {
|
||
const items = Projects.filter(p => p.status === st);
|
||
let filtered = items.filter(p => {
|
||
if (!q) return true;
|
||
return String(p.name || '').toLowerCase().includes(q);
|
||
});
|
||
if (Filters.month) {
|
||
filtered = filtered.filter(p => projectMonthKey(p) === Filters.month);
|
||
}
|
||
const col = document.createElement('div');
|
||
col.className = 'column';
|
||
col.dataset.status = st; // 用于拖拽目标识别
|
||
col.innerHTML = `<h3>${st} <span class="count">(${filtered.length})</span></h3>`;
|
||
// 列拖拽目标事件
|
||
col.addEventListener('dragover', (e) => { e.preventDefault(); col.classList.add('drag-over'); });
|
||
col.addEventListener('dragleave', () => { col.classList.remove('drag-over'); });
|
||
col.addEventListener('drop', (e) => {
|
||
e.preventDefault(); col.classList.remove('drag-over');
|
||
const pid = e.dataTransfer.getData('text/plain');
|
||
if (pid) { updateProject(pid, { status: st }); }
|
||
});
|
||
const limit = ShowLimits[st] || ShowStep;
|
||
filtered.slice(0, limit).forEach(p => {
|
||
const div = document.createElement('div');
|
||
div.className = 'project';
|
||
div.draggable = true;
|
||
div.addEventListener('dragstart', (e) => {
|
||
e.dataTransfer.setData('text/plain', p.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
div.classList.add('dragging');
|
||
});
|
||
div.addEventListener('dragend', () => { div.classList.remove('dragging'); });
|
||
const title = document.createElement('div');
|
||
title.className = 'title';
|
||
title.textContent = p.name;
|
||
title.style.cursor = 'pointer';
|
||
title.onclick = () => openProject(p.id);
|
||
const actions = document.createElement('div');
|
||
actions.className = 'actions';
|
||
const sel = document.createElement('select');
|
||
sel.className = 'select';
|
||
Statuses.forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s; opt.textContent = s; if (s === p.status) opt.selected = true; sel.appendChild(opt);
|
||
});
|
||
sel.onchange = () => updateProject(p.id, { status: sel.value });
|
||
const editBtn = document.createElement('button'); editBtn.className = 'btn'; editBtn.textContent = '编辑'; editBtn.onclick = () => openProject(p.id);
|
||
const delBtn = document.createElement('button'); delBtn.className = 'btn danger'; delBtn.textContent = '删除'; delBtn.onclick = () => deleteProject(p.id);
|
||
actions.appendChild(sel); actions.appendChild(editBtn); actions.appendChild(delBtn);
|
||
div.appendChild(title);
|
||
div.appendChild(actions);
|
||
col.appendChild(div);
|
||
});
|
||
if (filtered.length > limit) {
|
||
const more = document.createElement('button');
|
||
more.className = 'btn';
|
||
more.textContent = `显示更多... (${filtered.length - limit})`;
|
||
more.onclick = () => { ShowLimits[st] = limit + ShowStep; renderBoard(); };
|
||
col.appendChild(more);
|
||
}
|
||
board.appendChild(col);
|
||
});
|
||
}
|
||
|
||
async function createProject() {
|
||
const nameInput = document.getElementById('newProjectName');
|
||
const name = (nameInput.value || '').trim();
|
||
if (!name) { alert('请输入项目名称'); return; }
|
||
const resp = await api('api.php?action=create_project', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name })
|
||
});
|
||
if (resp && resp.ok) { nameInput.value=''; await loadProjects(); } else { alert(resp?.message || '创建失败'); }
|
||
}
|
||
|
||
async function updateProject(id, updates) {
|
||
const resp = await api('api.php?action=update_project', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, ...updates })
|
||
});
|
||
if (resp && resp.ok) { await loadProjects(); } else { alert(resp?.message || '更新失败'); }
|
||
}
|
||
|
||
async function deleteProject(id) {
|
||
if (!confirm('确认删除该项目及其笔记吗?')) return;
|
||
const resp = await api('api.php?action=delete_project', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id })
|
||
});
|
||
if (resp && resp.ok) { await loadProjects(); } else { alert(resp?.message || '删除失败'); }
|
||
}
|
||
|
||
async function openProject(id) {
|
||
// 改为跳转到独立详情页,不再使用悬浮弹窗
|
||
location.href = 'project.php?id=' + encodeURIComponent(id);
|
||
}
|
||
function closeProjectModal() { document.getElementById('projectModalMask').style.display = 'none'; CurrentProjectId = null; }
|
||
|
||
function renderNotes(notes) {
|
||
const list = document.getElementById('notesList');
|
||
list.innerHTML = '';
|
||
notes.forEach(n => {
|
||
const div = document.createElement('div'); div.className = 'note';
|
||
// 预览在上方:评论气泡样式
|
||
const preview = document.createElement('div'); preview.className = 'note-preview note-bubble';
|
||
const ta = document.createElement('textarea'); ta.className = 'input'; ta.style.width = '100%'; ta.style.minHeight = '80px'; ta.value = n.content || '';
|
||
preview.innerHTML = noteTextToHtml(ta.value || '');
|
||
enhancePreview(preview);
|
||
const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = `创建: ${n.created_at || ''} 更新: ${n.updated_at || ''}`;
|
||
const bar = document.createElement('div'); bar.style.display = 'flex'; bar.style.gap='8px'; bar.style.marginTop='6px';
|
||
const save = document.createElement('button'); save.className = 'btn'; save.textContent = '保存修改'; save.onclick = () => updateNote(n.id, ta.value);
|
||
const del = document.createElement('button'); del.className = 'btn danger'; del.textContent = '删除'; del.onclick = () => deleteNote(n.id);
|
||
const addImgBtn = document.createElement('button'); addImgBtn.className = 'btn'; addImgBtn.textContent = '添加图片';
|
||
const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.className = 'hidden';
|
||
addImgBtn.onclick = () => fileInput.click();
|
||
fileInput.onchange = async () => {
|
||
const f = fileInput.files && fileInput.files[0];
|
||
if (!f) return;
|
||
const up = await uploadFile(f);
|
||
if (up && up.ok && up.url) {
|
||
const md = `\n\n`;
|
||
insertAtCursor(ta, md);
|
||
preview.innerHTML = noteTextToHtml(ta.value || '');
|
||
enhancePreview(preview);
|
||
fileInput.value = '';
|
||
} else {
|
||
alert(up?.message || '图片上传失败');
|
||
}
|
||
};
|
||
ta.addEventListener('input', () => { preview.innerHTML = noteTextToHtml(ta.value || ''); enhancePreview(preview); });
|
||
bar.appendChild(save); bar.appendChild(del); bar.appendChild(addImgBtn); bar.appendChild(fileInput);
|
||
// 排列顺序:预览气泡 -> 元信息 -> 编辑框 -> 操作条
|
||
div.appendChild(preview);
|
||
div.appendChild(meta);
|
||
div.appendChild(ta);
|
||
div.appendChild(bar);
|
||
list.appendChild(div);
|
||
|
||
// 允许直接粘贴或拖拽图片/文件到笔记文本框
|
||
ta.addEventListener('paste', async (e) => {
|
||
const items = e.clipboardData?.items || [];
|
||
const files = [];
|
||
for (const it of items) {
|
||
if (it.kind === 'file') { const f = it.getAsFile(); if (f) files.push(f); }
|
||
}
|
||
if (files.length) {
|
||
e.preventDefault();
|
||
await handleFilesInsert(ta, files);
|
||
preview.innerHTML = noteTextToHtml(ta.value || '');
|
||
enhancePreview(preview);
|
||
}
|
||
});
|
||
ta.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||
ta.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
const files = Array.from(e.dataTransfer?.files || []);
|
||
if (files.length) {
|
||
await handleFilesInsert(ta, files);
|
||
preview.innerHTML = noteTextToHtml(ta.value || '');
|
||
enhancePreview(preview);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function applyProjectMeta() {
|
||
if (!CurrentProjectId) return;
|
||
const name = document.getElementById('renameProjectInput').value.trim();
|
||
const status = document.getElementById('statusSelect').value;
|
||
const resp = await api('api.php?action=update_project', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: CurrentProjectId, name, status })
|
||
});
|
||
const msg = document.getElementById('modalMsg');
|
||
if (resp && resp.ok) { msg.textContent = '项目已保存'; await loadProjects(); openProject(CurrentProjectId); } else { msg.textContent = resp?.message || '保存失败'; }
|
||
}
|
||
|
||
async function deleteCurrentProject() { if (CurrentProjectId) { await deleteProject(CurrentProjectId); closeProjectModal(); } }
|
||
|
||
async function addNote() {
|
||
if (!CurrentProjectId) return;
|
||
const content = document.getElementById('newNoteInput').value.trim();
|
||
if (!content) { alert('请输入笔记内容'); return; }
|
||
const resp = await api('api.php?action=add_note', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: CurrentProjectId, content })
|
||
});
|
||
if (resp && resp.ok) { document.getElementById('newNoteInput').value=''; await loadProjects(); openProject(CurrentProjectId); } else { alert(resp?.message || '添加失败'); }
|
||
}
|
||
|
||
async function updateNote(note_id, content) {
|
||
if (!CurrentProjectId) return;
|
||
const resp = await api('api.php?action=update_note', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: CurrentProjectId, note_id, content })
|
||
});
|
||
if (resp && resp.ok) { await loadProjects(); openProject(CurrentProjectId); } else { alert(resp?.message || '保存失败'); }
|
||
}
|
||
|
||
async function deleteNote(note_id) {
|
||
if (!CurrentProjectId) return;
|
||
if (!confirm('确认删除该笔记吗?')) return;
|
||
const resp = await api('api.php?action=delete_note', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: CurrentProjectId, note_id })
|
||
});
|
||
if (resp && resp.ok) { await loadProjects(); openProject(CurrentProjectId); } else { alert(resp?.message || '删除失败'); }
|
||
}
|
||
|
||
async function exportProjectHtml() {
|
||
if (!CurrentProjectId) return;
|
||
const resp = await api('api.php?action=export_project_html&id='+encodeURIComponent(CurrentProjectId));
|
||
if (resp && resp.ok && resp.url) {
|
||
window.open(resp.url, '_blank');
|
||
// 导出后刷新“导出管理”列表
|
||
await loadExports();
|
||
} else {
|
||
alert(resp?.message || '导出失败');
|
||
}
|
||
}
|
||
|
||
// 主动清理未使用的上传文件(后台扫描 data/uploads 与所有笔记引用)
|
||
async function cleanupUnusedUploads() {
|
||
const resp = await api('api.php?action=cleanup_unused_uploads');
|
||
if (resp && resp.ok) {
|
||
const d = resp.cleanup?.deleted ?? 0;
|
||
alert(`清理完成:删除未引用文件 ${d} 个`);
|
||
await loadProjects(); if (CurrentProjectId) openProject(CurrentProjectId);
|
||
} else {
|
||
alert(resp?.message || '清理失败');
|
||
}
|
||
}
|
||
|
||
// 文件上传(图片)
|
||
async function uploadFile(file) {
|
||
const fd = new FormData(); fd.append('file', file);
|
||
return api('api.php?action=upload_file', { method: 'POST', body: fd });
|
||
}
|
||
|
||
// 将笔记文本转换为HTML(支持 Markdown 图片:)
|
||
function noteTextToHtml(text) {
|
||
if (!text) return '';
|
||
const imgReg = /!\[([^\]]*)\]\(([^\)]+)\)/u;
|
||
const linkReg = /\[([^\]]+)\]\(([^\)]+)\)/u; // 链接(图片将由 imgReg 优先匹配)
|
||
let out = '';
|
||
let pos = 0;
|
||
while (true) {
|
||
const imgM = imgReg.exec(text.slice(pos));
|
||
const linkM = linkReg.exec(text.slice(pos));
|
||
const imgPos = imgM ? imgM.index : -1;
|
||
const linkPos = linkM ? linkM.index : -1;
|
||
if (imgPos < 0 && linkPos < 0) break;
|
||
const nextRel = (imgPos >=0 && (linkPos < 0 || imgPos <= linkPos)) ? imgPos : linkPos;
|
||
out += escapeHtml(text.slice(pos, pos + nextRel)).replace(/\n/g, '<br>');
|
||
if (nextRel === imgPos) {
|
||
const alt = escapeHtml(imgM[1] || '');
|
||
const url = escapeHtml(imgM[2] || '');
|
||
out += `<img src="${url}" alt="${alt}">`;
|
||
pos += imgPos + imgM[0].length;
|
||
} else {
|
||
const label = escapeHtml(linkM[1] || '');
|
||
const urlRaw = linkM[2] || '';
|
||
const url = escapeHtml(urlRaw);
|
||
if (isImageUrl(urlRaw)) {
|
||
out += `<img src="${url}" alt="${label}">`;
|
||
} else {
|
||
out += `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||
}
|
||
pos += linkPos + linkM[0].length;
|
||
}
|
||
}
|
||
out += escapeHtml(text.slice(pos)).replace(/\n/g, '<br>');
|
||
return out;
|
||
}
|
||
function escapeHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
function insertAtCursor(ta, text) {
|
||
const start = ta.selectionStart ?? ta.value.length; const end = ta.selectionEnd ?? start;
|
||
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
|
||
ta.focus();
|
||
ta.selectionStart = ta.selectionEnd = start + text.length;
|
||
ta.dispatchEvent(new Event('input'));
|
||
}
|
||
|
||
// 预览增强:点击图片在新窗口查看原图
|
||
function enhancePreview(previewEl) {
|
||
previewEl.querySelectorAll('img').forEach(img => {
|
||
img.addEventListener('click', () => { openImageLightbox(img.src, img.alt || ''); });
|
||
});
|
||
}
|
||
|
||
function isImageFile(file) {
|
||
const t = (file?.type || '').toLowerCase();
|
||
if (t.startsWith('image/')) return true;
|
||
const name = file?.name || '';
|
||
return /\.(png|jpg|jpeg|gif|webp)$/i.test(name);
|
||
}
|
||
async function handleFilesInsert(ta, files) {
|
||
for (const f of files) {
|
||
const up = await uploadFile(f);
|
||
if (up && up.ok && up.url) {
|
||
const fname = (up.name || f.name || '附件');
|
||
const url = up.url;
|
||
const md = isImageFile(f) ? `\n\n` : `\n[${fname}](${url})\n`;
|
||
insertAtCursor(ta, md);
|
||
} else {
|
||
alert(up?.message || `上传失败:${f.name}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function isImageUrl(url) {
|
||
return /\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i.test(url) || /^data:image\//i.test(url);
|
||
}
|
||
|
||
// ====== 图片浮窗(Lightbox) ======
|
||
let _imgLightboxEl = null;
|
||
function ensureImageLightbox() {
|
||
if (_imgLightboxEl) return _imgLightboxEl;
|
||
const mask = document.createElement('div');
|
||
mask.className = 'img-lightbox-mask';
|
||
const content = document.createElement('div');
|
||
content.className = 'img-lightbox-content';
|
||
const img = document.createElement('img');
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'img-lightbox-close'; closeBtn.textContent = '×';
|
||
content.appendChild(img); content.appendChild(closeBtn);
|
||
mask.appendChild(content);
|
||
document.body.appendChild(mask);
|
||
// 交互
|
||
const close = () => { mask.style.display = 'none'; document.body.style.overflow = ''; document.removeEventListener('keydown', escHandler); };
|
||
const escHandler = (e) => { if (e.key === 'Escape') close(); };
|
||
mask.addEventListener('click', close);
|
||
content.addEventListener('click', (e) => e.stopPropagation());
|
||
closeBtn.addEventListener('click', close);
|
||
_imgLightboxEl = mask;
|
||
return _imgLightboxEl;
|
||
}
|
||
function openImageLightbox(src, alt) {
|
||
const el = ensureImageLightbox();
|
||
const img = el.querySelector('img');
|
||
img.src = src; img.alt = alt || '';
|
||
el.style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
const escHandler = (e) => { if (e.key === 'Escape') { el.style.display='none'; document.body.style.overflow=''; document.removeEventListener('keydown', escHandler); } };
|
||
document.addEventListener('keydown', escHandler);
|
||
}
|
||
|
||
// ====== 月份曲线图 ======
|
||
let chartInstance = null;
|
||
function getLast12MonthsLabels() {
|
||
const labels = [];
|
||
const now = new Date();
|
||
for (let i = 11; i >= 0; i--) {
|
||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||
const y = d.getFullYear();
|
||
const m = (d.getMonth() + 1).toString().padStart(2, '0');
|
||
labels.push(`${y}-${m}`);
|
||
}
|
||
return labels;
|
||
}
|
||
function monthKeyFromDateStr(str) {
|
||
// 输入格式:YYYY-MM-DD HH:mm:ss
|
||
if (!str || typeof str !== 'string' || str.length < 7) return null;
|
||
const m = str.slice(0,7);
|
||
// 基础校验
|
||
if (!/^\d{4}-\d{2}$/.test(m)) return null;
|
||
return m;
|
||
}
|
||
function computeMonthlySeries(labels) {
|
||
const colorMap = { '待做': '#3b82f6', '进行': '#f59e0b', '完成': '#10b981', '异常': '#ef4444' };
|
||
const series = {};
|
||
Statuses.forEach(st => { series[st] = labels.map(_ => 0); });
|
||
Projects.forEach(p => {
|
||
// 优先使用创建月份;没有则使用更新时间;都没有则归入当前月
|
||
const mk = monthKeyFromDateStr(p.created_at || p.updated_at || '') || labels[labels.length - 1];
|
||
if (!mk) return; // 略过缺少时间的项目
|
||
const idx = labels.indexOf(mk);
|
||
if (idx === -1) return; // 不在最近12个月
|
||
const st = p.status || '待做';
|
||
if (!series[st]) series[st] = labels.map(_ => 0);
|
||
series[st][idx] += 1;
|
||
});
|
||
const datasets = Statuses.map(st => ({
|
||
label: st,
|
||
data: series[st],
|
||
tension: 0.25,
|
||
borderColor: colorMap[st],
|
||
backgroundColor: colorMap[st],
|
||
pointRadius: 3,
|
||
borderWidth: 2,
|
||
fill: false,
|
||
}));
|
||
return datasets;
|
||
}
|
||
function drawSimpleChart(canvas, labels, datasets) {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const cw = canvas.clientWidth || 600;
|
||
const ch = canvas.clientHeight || 140;
|
||
canvas.width = Math.floor(cw * dpr);
|
||
canvas.height = Math.floor(ch * dpr);
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(dpr, dpr);
|
||
const margin = { left: 36, right: 12, top: 12, bottom: 28 };
|
||
const plotW = cw - margin.left - margin.right;
|
||
const plotH = ch - margin.top - margin.bottom;
|
||
// 背景
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(0, 0, cw, ch);
|
||
// 计算最大值
|
||
let maxY = 0;
|
||
datasets.forEach(ds => ds.data.forEach(v => { if (v > maxY) maxY = v; }));
|
||
if (maxY === 0) maxY = 5;
|
||
const stepX = labels.length > 1 ? plotW / (labels.length - 1) : plotW;
|
||
const scaleY = plotH / maxY;
|
||
// 坐标轴
|
||
ctx.strokeStyle = '#e5e7eb';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(margin.left, margin.top);
|
||
ctx.lineTo(margin.left, margin.top + plotH);
|
||
ctx.lineTo(margin.left + plotW, margin.top + plotH);
|
||
ctx.stroke();
|
||
// y 轴刻度
|
||
ctx.fillStyle = '#6b7280';
|
||
ctx.font = '12px Arial';
|
||
for (let i = 0; i <= 4; i++) {
|
||
const yVal = Math.round((maxY * i) / 4);
|
||
const y = margin.top + plotH - yVal * scaleY;
|
||
ctx.fillText(String(yVal), 6, y + 4);
|
||
ctx.strokeStyle = '#f3f4f6';
|
||
ctx.beginPath();
|
||
ctx.moveTo(margin.left, y);
|
||
ctx.lineTo(margin.left + plotW, y);
|
||
ctx.stroke();
|
||
}
|
||
// x 轴标签(每隔2个月显示一次)
|
||
for (let i = 0; i < labels.length; i += Math.ceil(labels.length / 6)) {
|
||
const x = margin.left + i * stepX;
|
||
ctx.fillStyle = '#6b7280';
|
||
ctx.save();
|
||
ctx.translate(x, margin.top + plotH + 14);
|
||
ctx.rotate(-Math.PI / 8);
|
||
ctx.fillText(labels[i], 0, 0);
|
||
ctx.restore();
|
||
}
|
||
// 折线
|
||
datasets.forEach(ds => {
|
||
ctx.strokeStyle = ds.borderColor;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < ds.data.length; i++) {
|
||
const x = margin.left + i * stepX;
|
||
const y = margin.top + plotH - ds.data[i] * scaleY;
|
||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
// 点
|
||
ctx.fillStyle = ds.borderColor;
|
||
for (let i = 0; i < ds.data.length; i++) {
|
||
const x = margin.left + i * stepX;
|
||
const y = margin.top + plotH - ds.data[i] * scaleY;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
});
|
||
// 简易图例
|
||
let lx = margin.left; let ly = margin.top - 2;
|
||
datasets.forEach(ds => {
|
||
ctx.fillStyle = ds.borderColor;
|
||
ctx.fillRect(lx, ly, 10, 10);
|
||
ctx.fillStyle = '#374151';
|
||
ctx.fillText(ds.label, lx + 14, ly + 10);
|
||
lx += 60;
|
||
});
|
||
}
|
||
function renderMonthlyChart() {
|
||
const labels = getLast12MonthsLabels();
|
||
const datasets = computeMonthlySeries(labels);
|
||
const canvas = document.getElementById('monthlyChart');
|
||
const msg = document.getElementById('chartMsg');
|
||
if (window.Chart) {
|
||
const ctx = canvas.getContext('2d');
|
||
if (chartInstance) { chartInstance.destroy(); }
|
||
chartInstance = new Chart(ctx, {
|
||
type: 'line',
|
||
data: { labels, datasets },
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false, // 父容器固定高度,避免在Flex布局下无限增高
|
||
plugins: {
|
||
legend: { position: 'bottom' },
|
||
tooltip: { mode: 'index', intersect: false }
|
||
},
|
||
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
||
scales: {
|
||
y: { beginAtZero: true, ticks: { precision:0 } },
|
||
x: { grid: { display: false } }
|
||
}
|
||
}
|
||
});
|
||
msg.textContent = '';
|
||
} else {
|
||
// 无法加载外部CDN时,使用内置简易绘制
|
||
msg.textContent = '图表库未加载(可能网络限制或被拦截),已切换为内置简易曲线图。';
|
||
drawSimpleChart(canvas, labels, datasets);
|
||
}
|
||
}
|
||
|
||
// 初次加载
|
||
loadProjects();
|
||
|
||
// 对外暴露(给内联按钮用)
|
||
window.createProject = createProject;
|
||
window.updateProject = updateProject;
|
||
window.deleteProject = deleteProject;
|
||
window.openProject = openProject;
|
||
window.closeProjectModal = closeProjectModal;
|
||
window.applyProjectMeta = applyProjectMeta;
|
||
window.deleteCurrentProject = deleteCurrentProject;
|
||
window.addNote = addNote;
|
||
window.updateNote = updateNote;
|
||
window.deleteNote = deleteNote;
|
||
window.exportProjectHtml = exportProjectHtml;
|
||
window.cleanupUnusedUploads = cleanupUnusedUploads;
|
||
|
||
// 绑定筛选与搜索(在脚本加载后执行一次)
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const sel = document.getElementById('statusFilterSelect');
|
||
if (sel) sel.addEventListener('change', () => { Filters.status = sel.value || '全部'; renderBoard(); });
|
||
const input = document.getElementById('projectSearchInput');
|
||
if (input) input.addEventListener('input', () => { Filters.search = input.value || ''; renderBoard(); });
|
||
const monthInput = document.getElementById('monthFilterInput');
|
||
if (monthInput) monthInput.addEventListener('change', () => { Filters.month = monthInput.value || ''; renderBoard(); });
|
||
const clearBtn = document.getElementById('monthFilterClearBtn');
|
||
if (clearBtn) clearBtn.addEventListener('click', () => { Filters.month=''; if (monthInput) monthInput.value=''; renderBoard(); });
|
||
|
||
// 新增笔记条中的“添加图片”支持
|
||
const newNoteBtn = document.getElementById('newNoteAddImageBtn');
|
||
const newNoteFile = document.getElementById('newNoteImageInput');
|
||
const newNoteInput = document.getElementById('newNoteInput');
|
||
if (newNoteBtn && newNoteFile && newNoteInput) {
|
||
newNoteBtn.addEventListener('click', () => newNoteFile.click());
|
||
newNoteFile.addEventListener('change', async () => {
|
||
const f = newNoteFile.files && newNoteFile.files[0];
|
||
if (!f) return;
|
||
const up = await uploadFile(f);
|
||
if (up && up.ok && up.url) {
|
||
const md = isImageFile(f) ? `\n\n` : `\n[${up.name || f.name || '附件'}](${up.url})\n`;
|
||
newNoteInput.value = (newNoteInput.value || '') + md;
|
||
newNoteFile.value = '';
|
||
} else {
|
||
alert(up?.message || '图片上传失败');
|
||
}
|
||
});
|
||
// 允许粘贴/拖拽到新增笔记输入框
|
||
newNoteInput.addEventListener('paste', async (e) => {
|
||
const items = e.clipboardData?.items || [];
|
||
const files = [];
|
||
for (const it of items) { if (it.kind === 'file') { const f = it.getAsFile(); if (f) files.push(f); } }
|
||
if (files.length) {
|
||
e.preventDefault();
|
||
await handleFilesInsert(newNoteInput, files);
|
||
}
|
||
});
|
||
newNoteInput.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||
newNoteInput.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
const files = Array.from(e.dataTransfer?.files || []);
|
||
if (files.length) { await handleFilesInsert(newNoteInput, files); }
|
||
});
|
||
}
|
||
});
|
||
|
||
// ====== 导出管理 ======
|
||
let ExportFilesCache = [];
|
||
let ExportsFilters = { search: '', month: '' };
|
||
async function loadExports() {
|
||
const resp = await api('api.php?action=list_exports');
|
||
ExportFilesCache = Array.isArray(resp?.files) ? resp.files : [];
|
||
renderExports(ExportFilesCache);
|
||
}
|
||
|
||
function renderExports(files) {
|
||
const box = document.getElementById('exportsList');
|
||
if (!box) return;
|
||
// 过滤
|
||
const q = (ExportsFilters.search || '').trim().toLowerCase();
|
||
const month = ExportsFilters.month || '';
|
||
let filtered = files.slice();
|
||
if (q) filtered = filtered.filter(f => String(f.name || '').toLowerCase().includes(q));
|
||
if (month) filtered = filtered.filter(f => monthKeyFromDateStr(f.mtime || '') === month);
|
||
// 数量统计
|
||
const countEl = document.getElementById('exportsCount');
|
||
if (countEl) countEl.textContent = `共 ${filtered.length} 项${month ? ` · 月份:${month}` : ''}${q ? ` · 关键词:${q}` : ''}`;
|
||
if (!filtered.length) { box.innerHTML = '<div class="muted">暂无匹配的导出文件</div>'; return; }
|
||
box.innerHTML = '';
|
||
filtered.forEach(f => {
|
||
const row = document.createElement('div');
|
||
row.className = 'export-item';
|
||
const info = document.createElement('div');
|
||
info.className = 'export-info';
|
||
const sizeKB = Math.round((f.size || 0) / 1024);
|
||
info.textContent = `${f.name} · ${f.mtime} · ${sizeKB} KB`;
|
||
const acts = document.createElement('div'); acts.className = 'export-actions';
|
||
const openBtn = document.createElement('button'); openBtn.className = 'btn'; openBtn.textContent = '打开'; openBtn.onclick = () => window.open(f.url, '_blank');
|
||
const shareBtn = document.createElement('button'); shareBtn.className = 'btn'; shareBtn.textContent = '分享';
|
||
shareBtn.onclick = async () => {
|
||
const abs = new URL(f.url, window.location.href).href;
|
||
try { await navigator.clipboard.writeText(abs); alert('已复制链接到剪贴板:\n' + abs); } catch(e) { prompt('复制失败,请手动复制:', abs); }
|
||
};
|
||
const delBtn = document.createElement('button'); delBtn.className = 'btn danger'; delBtn.textContent = '删除';
|
||
delBtn.onclick = async () => {
|
||
if (!confirm(`确认删除导出文件:${f.name}?`)) return;
|
||
const r = await api('api.php?action=delete_export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: f.name }) });
|
||
if (r && r.ok) { await loadExports(); } else { alert(r?.message || '删除失败'); }
|
||
};
|
||
acts.appendChild(openBtn); acts.appendChild(shareBtn); acts.appendChild(delBtn);
|
||
row.appendChild(info); row.appendChild(acts);
|
||
box.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// 初始加载导出列表
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// 初次加载
|
||
loadExports();
|
||
// 绑定筛选控件
|
||
const searchInput = document.getElementById('exportsSearchInput');
|
||
if (searchInput) searchInput.addEventListener('input', () => { ExportsFilters.search = searchInput.value || ''; renderExports(ExportFilesCache); });
|
||
const monthInput = document.getElementById('exportsMonthInput');
|
||
if (monthInput) monthInput.addEventListener('change', () => { ExportsFilters.month = monthInput.value || ''; renderExports(ExportFilesCache); });
|
||
const clearBtn = document.getElementById('exportsMonthClearBtn');
|
||
if (clearBtn) clearBtn.addEventListener('click', () => { ExportsFilters.month=''; if (monthInput) monthInput.value=''; renderExports(ExportFilesCache); });
|
||
});
|