Files
XinagMuKanBan/assets/js/project.js
2025-11-18 14:28:07 +08:00

457 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 项目详情页脚本(不使用悬浮弹窗)
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) { return null; }
}
return res.text();
};
let CurrentProjectId = null;
let CurrentProject = null;
function escapeHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function isImageUrl(url){ return /\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i.test(url) || /^data:image\//i.test(url); }
// 将笔记文本转换为HTML支持 Markdown 图片:![alt](url)
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 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 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);
}
function enhancePreview(previewEl) {
previewEl.querySelectorAll('img').forEach(img => {
img.addEventListener('click', () => { openImageLightbox(img.src, img.alt || ''); });
});
}
// ====== 图片浮窗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);
}
async function uploadFile(file) {
const fd = new FormData(); fd.append('file', file);
return api('api.php?action=upload_file', { method: 'POST', body: fd });
}
async function loadProject() {
const id = window.PAGE_PROJECT_ID || '';
if (!id) { document.getElementById('pageMsg').textContent = '缺少项目ID'; return; }
const data = await api('api.php?action=get_project&id=' + encodeURIComponent(id));
if (data && data.ok) {
CurrentProject = data.project; CurrentProjectId = CurrentProject.id;
document.getElementById('pageProjectTitle').textContent = CurrentProject.name;
document.getElementById('pageProjectStatus').textContent = '· 当前状态:' + CurrentProject.status;
document.getElementById('renameProjectInput').value = CurrentProject.name;
document.getElementById('statusSelect').value = CurrentProject.status;
// ETA 输入显示控制(仅进行中显示)
const etaInput = document.getElementById('etaInput');
if (etaInput) {
const key = 'project_eta_' + CurrentProjectId;
const saved = localStorage.getItem(key) || '';
etaInput.style.display = (CurrentProject.status === '进行') ? '' : 'none';
if (saved) etaInput.value = saved;
}
updateProjectReminder();
renderNotes(CurrentProject.notes || []);
} else {
document.getElementById('pageMsg').textContent = (data && data.message) ? data.message : '加载失败';
}
}
function timeAgo(str) {
if (!str) return '';
const d = new Date(str.replace(/-/g,'/')); // iOS 兼容
if (isNaN(d.getTime())) return str;
const diff = Date.now() - d.getTime();
const sec = Math.floor(diff/1000); if (sec < 60) return sec + '秒前';
const min = Math.floor(sec/60); if (min < 60) return min + '分钟前';
const hour = Math.floor(min/60); if (hour < 24) return hour + '小时前';
const day = Math.floor(hour/24); if (day < 30) return day + '天前';
const month = Math.floor(day/30); if (month < 12) return month + '个月前';
const year = Math.floor(month/12); return year + '年前';
}
function durationSince(str) {
if (!str) return '';
const d = new Date(str.replace(/-/g,'/'));
if (isNaN(d.getTime())) return '';
let ms = Date.now() - d.getTime();
if (ms < 0) ms = 0;
const days = Math.floor(ms / (24*3600*1000));
ms -= days * 24*3600*1000;
const hours = Math.floor(ms / (3600*1000));
ms -= hours * 3600*1000;
const mins = Math.floor(ms / (60*1000));
const parts = [];
if (days > 0) parts.push(days + '天');
if (hours > 0) parts.push(hours + '小时');
if (days === 0 && mins > 0) parts.push(mins + '分钟');
return parts.join('') || '刚刚';
}
function updateProjectReminder() {
try {
const box = document.getElementById('projectRemind'); if (!box || !CurrentProject) return;
const p = CurrentProject;
let msg = '';
if (p.status === '异常') {
const base = p.updated_at || p.created_at || '';
msg = `异常状态已持续:${durationSince(base)}。请尽快处理。`;
} else if (p.status === '待做') {
const base = p.created_at || '';
msg = `项目创建至今已等待:${durationSince(base)}。建议尽快开始。`;
} else if (p.status === '进行') {
const base = p.created_at || p.updated_at || '';
const howlong = durationSince(base);
const key = 'project_eta_' + p.id;
const eta = localStorage.getItem(key) || '';
if (eta) {
const ed = new Date(eta + 'T00:00:00');
const now = new Date();
let diff = ed.getTime() - now.getTime();
const daysLeft = Math.ceil(diff / (24*3600*1000));
const tail = daysLeft >= 0 ? `剩余约 ${daysLeft}` : `已超期 ${Math.abs(daysLeft)}`;
msg = `进行中:已用时 ${howlong};计划完成:${eta}${tail})。`;
} else {
msg = `进行中:已用时 ${howlong}。可设置“计划完成日期”以显示剩余天数。`;
}
} else if (p.status === '完成') {
const base = p.updated_at || p.created_at || '';
msg = `已完成(${timeAgo(base)})。`;
}
// 原始提醒文案(用于 AI 的原始输入)
box.textContent = msg;
box.setAttribute('data-raw', msg);
// 尝试从后端获取已保存的AI润色文本持久化在服务器JSON
(async () => {
try {
const resp = await api('api.php?action=get_ai_reminder&id=' + encodeURIComponent(CurrentProjectId));
if (resp && resp.ok && resp.text) {
box.textContent = resp.text;
}
} catch (e) {}
})();
} catch (e) {}
}
// ====== AI 自动润色提醒 ======
async function aiEnhanceReminderOnce() {
try {
const box = document.getElementById('projectRemind');
if (!box || !CurrentProjectId) return;
const raw = (box.getAttribute('data-raw') || box.textContent || '').trim();
if (!raw) return;
// 进度提示:结合笔记进行润色
box.textContent = 'AI正在结合笔记进行润色…';
const lastKey = 'ai_last_ts_' + CurrentProjectId;
const resp = await api('api.php?action=ai_enrich_reminder', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw: raw, project: { id: CurrentProjectId, name: CurrentProject?.name, status: CurrentProject?.status }, include_notes: true })
});
if (resp && resp.ok && resp.text) {
box.textContent = resp.text;
try { localStorage.setItem(lastKey, String(Date.now())); } catch (e) {}
}
} catch (e) { /* 静默失败 */ }
}
// 已取消自动AI润色改为手动点击按钮触发
function firstLetter(name){ return (name || '记').trim().slice(0,1).toUpperCase(); }
function renderNotes(notes) {
const list = document.getElementById('notesList');
list.innerHTML = '';
notes.forEach(n => {
// Git风格卡片容器
const card = document.createElement('div'); card.className = 'note-git';
const header = document.createElement('div'); header.className = 'note-git-header';
const titleEl = document.createElement('span'); titleEl.className = 'note-git-title'; titleEl.textContent = '笔记';
titleEl.title = '点击以展开/收起编辑';
const timeEl = document.createElement('span'); timeEl.className = 'note-git-time'; timeEl.textContent = timeAgo(n.created_at || '') || (n.created_at || '');
header.appendChild(titleEl); header.appendChild(timeEl);
// 预览Git风格正文
const preview = document.createElement('div'); preview.className = 'note-git-body';
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 editWrap = document.createElement('div');
editWrap.style.marginTop = '6px';
editWrap.appendChild(ta);
editWrap.classList.add('hidden');
// 底部操作:左侧图标,右侧保存/删除/添加图片
const footer = document.createElement('div'); footer.className = 'note-footer';
const actionsRight = document.createElement('div'); actionsRight.className = 'note-actions-right';
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?.[0]; if (!f) return; if (!isImageFile(f)) { alert('请选择图片文件'); return; }
const up = await uploadFile(f);
if (up && up.ok && up.url) {
insertAtCursor(ta, `![image](${up.url})`);
preview.innerHTML = noteTextToHtml(ta.value || '');
enhancePreview(preview);
} else {
alert(up?.message || '上传失败');
}
fileInput.value = '';
};
ta.addEventListener('input', () => { preview.innerHTML = noteTextToHtml(ta.value || ''); enhancePreview(preview); });
// 点击标题“笔记”展开/收起编辑;双击正文也可展开编辑
const toggleEdit = () => {
const hidden = editWrap.classList.contains('hidden');
if (hidden) { editWrap.classList.remove('hidden'); footer.classList.remove('hidden'); }
else { editWrap.classList.add('hidden'); footer.classList.add('hidden'); }
};
titleEl.addEventListener('click', toggleEdit);
preview.addEventListener('dblclick', toggleEdit);
actionsRight.appendChild(save); actionsRight.appendChild(del); actionsRight.appendChild(addImgBtn); actionsRight.appendChild(fileInput);
footer.appendChild(actionsRight);
// 初始隐藏底部按钮,跟随编辑区一起展开/收起
footer.classList.add('hidden');
// 组装卡片:头部 -> 预览 -> 编辑(默认展开) -> 底部
card.appendChild(header);
card.appendChild(preview);
card.appendChild(editWrap);
card.appendChild(footer);
list.appendChild(card);
});
}
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('pageMsg');
if (resp && resp.ok) { msg.textContent = '项目已保存'; await loadProject(); } else { msg.textContent = resp?.message || '保存失败'; }
}
async function deleteProjectCurrent() {
if (!CurrentProjectId) return; if (!confirm('确认删除该项目吗?')) return;
const resp = await api('api.php?action=delete_project', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: CurrentProjectId })
});
if (resp && resp.ok) {
// 删除项目的同时清理保存的AI润色、上次时间、计划日期
try {
localStorage.removeItem('ai_last_ts_' + CurrentProjectId);
localStorage.removeItem('project_eta_' + CurrentProjectId);
} catch (e) {}
alert('已删除'); location.href = 'index.php';
} else { alert(resp?.message || '删除失败'); }
}
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 loadProject(); } 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 loadProject(); } 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 loadProject(); } 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');
} else {
alert(resp?.message || '导出失败');
}
}
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 loadProject();
} else {
alert(resp?.message || '清理失败');
}
}
document.addEventListener('DOMContentLoaded', () => {
// 退出登录按钮
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) logoutBtn.addEventListener('click', async () => {
try { await api('api.php?action=logout', { method: 'POST' }); } catch (e) {}
try { localStorage.removeItem('auth_ok'); } catch (e) {}
location.href = 'index.php';
});
// 绑定操作按钮
document.getElementById('saveMetaBtn').addEventListener('click', applyProjectMeta);
document.getElementById('deleteProjectBtn').addEventListener('click', deleteProjectCurrent);
document.getElementById('exportHtmlBtn').addEventListener('click', exportProjectHtml);
document.getElementById('cleanupBtn').addEventListener('click', cleanupUnusedUploads);
document.getElementById('addNoteBtn').addEventListener('click', addNote);
// 已移除前端 AI Key 输入,改为后端文件配置
// AI润色提示
const aiBtn = document.getElementById('aiEnhanceBtn');
if (aiBtn) {
aiBtn.addEventListener('click', async () => {
try {
const box = document.getElementById('projectRemind'); if (!box) return;
const raw = (box.getAttribute('data-raw') || box.textContent || '');
if (!raw) { alert('当前无提醒内容'); return; }
aiBtn.disabled = true; aiBtn.textContent = 'AI润色中...';
// 进度提示:结合笔记进行润色
box.textContent = 'AI正在结合笔记进行润色…';
const resp = await api('api.php?action=ai_enrich_reminder', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw: raw, project: { id: CurrentProjectId, name: CurrentProject?.name, status: CurrentProject?.status }, include_notes: true })
});
if (resp && resp.ok && resp.text) {
box.textContent = resp.text;
try { localStorage.setItem('ai_last_ts_' + CurrentProjectId, String(Date.now())); } catch (e) {}
}
else {
const msg = resp?.message || 'AI润色失败';
box.textContent = msg;
alert(msg);
}
} catch (e) { alert('AI调用失败'); }
finally { aiBtn.disabled = false; aiBtn.textContent = 'AI润色提示'; }
});
}
// 手动润色仅在点击“AI润色提示”时触发
// 计划完成日期(本地存储,不影响后端数据)
const etaInput = document.getElementById('etaInput');
if (etaInput) {
etaInput.addEventListener('change', () => {
if (!CurrentProjectId) return;
const key = 'project_eta_' + CurrentProjectId;
const v = etaInput.value || '';
if (v) localStorage.setItem(key, v); else localStorage.removeItem(key);
updateProjectReminder();
});
}
// 新增笔记添加图片
const addImgBtn = document.getElementById('newNoteAddImageBtn');
const imgInput = document.getElementById('newNoteImageInput');
const ta = document.getElementById('newNoteInput');
if (addImgBtn && imgInput && ta) {
addImgBtn.addEventListener('click', () => imgInput.click());
imgInput.addEventListener('change', async () => {
const f = imgInput.files?.[0]; if (!f) return; if (!isImageFile(f)) { alert('请选择图片文件'); return; }
const up = await uploadFile(f);
if (up && up.ok && up.url) {
insertAtCursor(ta, `![image](${up.url})`);
} else {
alert(up?.message || '上传失败');
}
imgInput.value = '';
});
}
// 加载项目
loadProject();
});