commit a3e86ac78350e690950cb7d758d13054e4ae1e18 Author: LL Date: Tue Nov 18 14:28:07 2025 +0800 初始化版本 diff --git a/404.html b/404.html new file mode 100644 index 0000000..6f17eaf --- /dev/null +++ b/404.html @@ -0,0 +1,7 @@ + +404 Not Found + +

404 Not Found

+
nginx
+ + \ No newline at end of file diff --git a/api.php b/api.php new file mode 100644 index 0000000..06c9dc2 --- /dev/null +++ b/api.php @@ -0,0 +1,510 @@ + $it) { if (($it['id'] ?? '') === $id) return $i; } + return -1; +} + +// 未登录则阻止除 login 以外的操作 +if ($action !== 'login') { + if (!isset($_SESSION['auth']) || !$_SESSION['auth']) { + resp_json(['ok' => false, 'message' => '未登录', 'need_login' => true]); + } +} + +switch ($action) { + case 'login': { + $b = body_json(); + $pwd = trim($b['password'] ?? ''); + if ($pwd === '123123') { + $_SESSION['auth'] = 1; + // 设置简单 Cookie,供导出HTML等静态页前端校验 + setcookie('auth_ok', '1', time() + 7*24*3600, '/'); + resp_json(['ok' => true]); + } else { + resp_json(['ok' => false, 'message' => '密码错误']); + } + } + case 'logout': { + $_SESSION['auth'] = 0; unset($_SESSION['auth']); + setcookie('auth_ok', '', time() - 3600, '/'); + resp_json(['ok' => true]); + } + case 'list_projects': { + $projects = readJson($dataFile); + resp_json(['ok' => true, 'projects' => $projects]); + } + case 'get_project': { + $id = $_GET['id'] ?? ''; + if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']); + $projects = readJson($dataFile); + $idx = find_index_by_id($projects, $id); + if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']); + resp_json(['ok' => true, 'project' => $projects[$idx]]); + } + case 'cleanup_unused_uploads': { + // 主动触发一次清理:删除 data/uploads 下未被任何笔记引用的文件 + $projects = readJson($dataFile); + $clean = cleanup_unused_uploads($projects, $uploadDir); + resp_json(['ok' => true, 'cleanup' => $clean]); + } + case 'create_project': { + $b = body_json(); + $name = trim($b['name'] ?? ''); + if ($name === '') resp_json(['ok' => false, 'message' => '项目名称不能为空']); + $projects = readJson($dataFile); + $p = [ + 'id' => uid(), + 'name' => $name, + 'status' => '待做', + 'notes' => [], + 'created_at' => nowStr(), + 'updated_at' => nowStr() + ]; + $projects[] = $p; + writeJson($dataFile, $projects); + resp_json(['ok' => true, 'project' => $p]); + } + case 'update_project': { + $b = body_json(); + $id = $b['id'] ?? ''; + if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']); + $projects = readJson($dataFile); + $idx = find_index_by_id($projects, $id); + if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']); + $name = isset($b['name']) ? trim($b['name']) : null; + $status = isset($b['status']) ? trim($b['status']) : null; + if ($name !== null && $name !== '') $projects[$idx]['name'] = $name; + if ($status !== null && in_array($status, ['待做','进行','完成','异常'])) $projects[$idx]['status'] = $status; + $projects[$idx]['updated_at'] = nowStr(); + writeJson($dataFile, $projects); + resp_json(['ok' => true, 'project' => $projects[$idx]]); + } + case 'delete_project': { + $b = body_json(); + $id = $b['id'] ?? ''; + if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']); + $projects = readJson($dataFile); + $idx = find_index_by_id($projects, $id); + if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']); + array_splice($projects, $idx, 1); + writeJson($dataFile, $projects); + // 同步删除该项目的AI润色存档 + $aiMap = readJson($aiTextFile); + if (isset($aiMap[$id])) { unset($aiMap[$id]); writeJson($aiTextFile, $aiMap); } + // 删除项目后,清理未被任何笔记引用的上传文件 + $clean = cleanup_unused_uploads($projects, $uploadDir); + resp_json(['ok' => true, 'cleanup' => $clean]); + } + case 'add_note': { + $b = body_json(); + $pid = $b['project_id'] ?? ''; + $content = trim($b['content'] ?? ''); + if ($pid === '' || $content === '') resp_json(['ok' => false, 'message' => '缺少参数']); + $projects = readJson($dataFile); + $idx = find_index_by_id($projects, $pid); + if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']); + $note = [ 'id' => uid(), 'content' => $content, 'created_at' => nowStr(), 'updated_at' => nowStr() ]; + if (!isset($projects[$idx]['notes']) || !is_array($projects[$idx]['notes'])) $projects[$idx]['notes'] = []; + $projects[$idx]['notes'][] = $note; + writeJson($dataFile, $projects); + resp_json(['ok' => true, 'note' => $note]); + } + case 'update_note': { + $b = body_json(); + $pid = $b['project_id'] ?? ''; + $nid = $b['note_id'] ?? ''; + $content = trim($b['content'] ?? ''); + if ($pid === '' || $nid === '') resp_json(['ok' => false, 'message' => '缺少参数']); + $projects = readJson($dataFile); + $pidx = find_index_by_id($projects, $pid); + if ($pidx < 0) resp_json(['ok' => false, 'message' => '项目不存在']); + $notes = $projects[$pidx]['notes'] ?? []; + $nidx = -1; foreach ($notes as $i => $n) { if (($n['id'] ?? '') === $nid) { $nidx = $i; break; } } + if ($nidx < 0) resp_json(['ok' => false, 'message' => '笔记不存在']); + if ($content !== '') { $projects[$pidx]['notes'][$nidx]['content'] = $content; } + $projects[$pidx]['notes'][$nidx]['updated_at'] = nowStr(); + writeJson($dataFile, $projects); + // 更新笔记后尝试清理未使用的上传文件(防止删除了图片引用后残留) + $clean = cleanup_unused_uploads($projects, $uploadDir); + resp_json(['ok' => true, 'note' => $projects[$pidx]['notes'][$nidx], 'cleanup' => $clean]); + } + case 'delete_note': { + $b = body_json(); + $pid = $b['project_id'] ?? ''; + $nid = $b['note_id'] ?? ''; + if ($pid === '' || $nid === '') resp_json(['ok' => false, 'message' => '缺少参数']); + $projects = readJson($dataFile); + $pidx = find_index_by_id($projects, $pid); + if ($pidx < 0) resp_json(['ok' => false, 'message' => '项目不存在']); + $notes = $projects[$pidx]['notes'] ?? []; + $nidx = -1; foreach ($notes as $i => $n) { if (($n['id'] ?? '') === $nid) { $nidx = $i; break; } } + if ($nidx < 0) resp_json(['ok' => false, 'message' => '笔记不存在']); + array_splice($projects[$pidx]['notes'], $nidx, 1); + writeJson($dataFile, $projects); + // 删除笔记后,清理未被任何笔记引用的上传文件 + $clean = cleanup_unused_uploads($projects, $uploadDir); + resp_json(['ok' => true, 'cleanup' => $clean]); + } + case 'export_project_html': { + $id = $_GET['id'] ?? ''; + if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']); + $projects = readJson($dataFile); + $idx = find_index_by_id($projects, $id); + if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']); + $p = $projects[$idx]; + $safeName = preg_replace('/[^\w\x{4e00}-\x{9fa5}]+/u', '_', $p['name']); + $fname = $exportDir . DIRECTORY_SEPARATOR . 'project_' . $safeName . '_' . substr($p['id'], 0, 8) . '.html'; + $html = "" . htmlspecialchars($p['name']) . " - 导出"; + $html .= '

' . htmlspecialchars($p['name']) . '

'; + $html .= '

状态:' . htmlspecialchars($p['status']) . '

'; + $html .= '

笔记

'; + foreach (($p['notes'] ?? []) as $n) { + $html .= '
' . render_note_content($n['content'] ?? '') . '
'; + $html .= '
创建:' . htmlspecialchars($n['created_at'] ?? '') . ' 更新:' . htmlspecialchars($n['updated_at'] ?? '') . '
'; + } + $html .= ''; + file_put_contents($fname, $html); + // 构造相对URL,便于在浏览器打开 + $rel = 'data/exports/' . basename($fname); + resp_json(['ok' => true, 'url' => $rel]); + } + case 'ai_enrich_reminder': { + // 使用火山引擎 Doubao 模型对提醒文案进行情感润色 + $b = body_json(); + $raw = trim($b['raw'] ?? ''); + $proj = $b['project'] ?? []; + $apiKey = trim($b['api_key'] ?? ''); + if ($raw === '') resp_json(['ok' => false, 'message' => '缺少原始提醒内容']); + if ($apiKey === '') { + // 尝试从本地文件读取 Key(可选) + $kf = $dataDir . DIRECTORY_SEPARATOR . 'ark_api_key.txt'; + if (is_file($kf)) { $apiKey = trim(@file_get_contents($kf)); } + } + if ($apiKey === '') resp_json(['ok' => false, 'message' => '缺少AI Key(请将密钥写入 data/ark_api_key.txt)']); + $name = isset($proj['name']) ? $proj['name'] : ''; + $status = isset($proj['status']) ? $proj['status'] : ''; + $pid = isset($proj['id']) ? $proj['id'] : ''; + // 可选:结合项目最近笔记进行润色 + $includeNotes = isset($b['include_notes']) ? !!($b['include_notes']) : true; // 默认结合笔记 + $notesSummary = ''; + if ($includeNotes && $pid !== '') { + $projectsAll = readJson($dataFile); + $pidx2 = find_index_by_id($projectsAll, $pid); + if ($pidx2 >= 0) { + $notesArr = $projectsAll[$pidx2]['notes'] ?? []; + // 取最近3条(末尾3条) + $cnt = count($notesArr); + $recent = []; + if ($cnt > 0) { + $recent = array_slice($notesArr, max(0, $cnt - 3)); + } + $pieces = []; + foreach ($recent as $nn) { + $content = trim($nn['content'] ?? ''); + if ($content !== '') { + // 去掉图片Markdown、URL等噪声 + $content = preg_replace('/!\[[^\]]*\]\([^\)]*\)/', '', $content); + $content = preg_replace('/https?:\/\/\S+/i', '', $content); + $content = trim($content); + // 截断避免过长 + if (function_exists('mb_strlen') && function_exists('mb_substr')) { + if (mb_strlen($content, 'UTF-8') > 120) { $content = mb_substr($content, 0, 120, 'UTF-8') . '…'; } + } else { + if (strlen($content) > 200) { $content = substr($content, 0, 200) . '…'; } + } + if ($content !== '') $pieces[] = $content; + } + } + if (!empty($pieces)) { $notesSummary = implode(';', $pieces); } + } + } + $prompt = "请将以下项目提醒改写为更具同理心、简洁明确且可执行的中文提示,长度不超过60字,并适当使用 emoji(最多2个),避免官方语气。\n" . + "只输出一条润色后的中文提醒,不要附加任何解释、评分、括号中的说明、标签或代码块。\n" . + "项目名称:" . $name . "\n状态:" . $status . "\n提醒:" . $raw; + if ($notesSummary !== '') { + $prompt .= "\n参考最近笔记要点:" . $notesSummary; + } + $payload = [ + 'model' => 'doubao-seed-1-6-251015', + 'max_completion_tokens' => 1024, + 'messages' => [ [ 'role' => 'user', 'content' => [ [ 'type' => 'text', 'text' => $prompt ] ] ] ], + 'reasoning_effort' => 'medium' + ]; + $ch = curl_init('https://ark.cn-beijing.volces.com/api/v3/chat/completions'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey ]); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($ch, CURLOPT_TIMEOUT, 20); + $resp = curl_exec($ch); + $err = curl_error($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($resp === false) resp_json(['ok' => false, 'message' => '请求失败: ' . $err]); + $j = json_decode($resp, true); + if (!is_array($j)) resp_json(['ok' => false, 'message' => '响应解析失败', 'code' => $code]); + // 提取文本内容 + $txt = ''; + // 1) 直接读取 output_text(有些版本会提供该字段) + if (isset($j['output_text']) && is_string($j['output_text'])) { $txt = $j['output_text']; } + // 2) choices.message.content 可能是字符串或数组 + if ($txt === '' && isset($j['choices'][0]['message']['content'])) { + $content = $j['choices'][0]['message']['content']; + if (is_string($content)) { $txt = $content; } + elseif (is_array($content)) { + foreach ($content as $c) { if (isset($c['text'])) { $txt .= $c['text']; } } + } + } + // 3) 兜底读取常见位置 + if ($txt === '' && isset($j['choices'][0]['message']['content'][0]['text'])) { + $txt = $j['choices'][0]['message']['content'][0]['text']; + } + if ($txt === '' && isset($j['data']['output_text'])) { $txt = $j['data']['output_text']; } + if ($txt === '') resp_json(['ok' => false, 'message' => '未获取到AI文本', 'raw_resp' => $j]); + // 仅保留第一行,并去掉末尾的括号型说明(例如:(字数合规…)或(...)) + $txt = trim((string)$txt); + $firstLine = preg_split('/\r?\n/', $txt)[0]; + $firstLine = trim($firstLine); + // 去掉结尾括号段,但保留正文中的内容 + $firstLine = preg_replace('/(?:([^)]*)|\([^\)]*\))\s*$/u', '', $firstLine); + $firstLine = trim($firstLine); + if ($firstLine === '') $firstLine = $txt; // 兜底 + $txt = $firstLine; + // 写入到 ai_reminder.json 中,按项目ID持久化 + if ($pid !== '') { + $aiMap = readJson($aiTextFile); + if (!is_array($aiMap)) $aiMap = []; + $aiMap[$pid] = [ 'text' => $txt, 'raw' => $raw, 'updated_at' => nowStr(), 'notes' => $notesSummary ]; + writeJson($aiTextFile, $aiMap); + } + resp_json(['ok' => true, 'text' => $txt, 'saved' => ($pid !== '')]); + } + case 'get_ai_reminder': { + // 获取指定项目的AI润色文本(如果存在) + $id = $_GET['id'] ?? ($_POST['id'] ?? ''); + if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']); + $aiMap = readJson($aiTextFile); + if (isset($aiMap[$id]) && isset($aiMap[$id]['text'])) { + $item = $aiMap[$id]; + resp_json(['ok' => true, 'text' => $item['text'], 'updated_at' => ($item['updated_at'] ?? '')]); + } else { + resp_json(['ok' => true, 'text' => '', 'updated_at' => '']); + } + } + case 'list_exports': { + // 列出 data/exports 下的导出文件(仅 .html) + $files = []; + if (is_dir($exportDir)) { + foreach (scandir($exportDir) as $f) { + if ($f === '.' || $f === '..') continue; + if (!preg_match('/\.html?$/i', $f)) continue; + $path = $exportDir . DIRECTORY_SEPARATOR . $f; + if (is_file($path)) { + $files[] = [ + 'name' => $f, + 'url' => 'data/exports/' . $f, + 'size' => filesize($path), + 'mtime' => date('Y-m-d H:i:s', filemtime($path)) + ]; + } + } + } + // 最新时间倒序 + usort($files, function($a,$b){ return strcmp($b['mtime'],$a['mtime']); }); + resp_json(['ok' => true, 'files' => $files]); + } + case 'delete_export': { + // 删除指定的导出文件(仅限 data/exports) + $b = body_json(); + $name = $b['name'] ?? ''; + if ($name === '') resp_json(['ok' => false, 'message' => '缺少文件名']); + $base = basename($name); // 防止路径穿越 + $path = $exportDir . DIRECTORY_SEPARATOR . $base; + if (!is_file($path)) resp_json(['ok' => false, 'message' => '文件不存在']); + if (!@unlink($path)) resp_json(['ok' => false, 'message' => '删除失败']); + resp_json(['ok' => true]); + } + case 'upload_file': { + // 允许上传图片或常见文档,保存到 data/uploads + if ($_SERVER['REQUEST_METHOD'] !== 'POST') resp_json(['ok' => false, 'message' => '仅支持POST']); + $file = $_FILES['file'] ?? ($_FILES['image'] ?? null); + if (!$file) resp_json(['ok' => false, 'message' => '未选择文件']); + if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) resp_json(['ok' => false, 'message' => '上传错误: ' . ($file['error'] ?? '未知')]); + $name = $file['name'] ?? ('img_' . uniqid()); + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + $allow = ['png','jpg','jpeg','gif','webp','pdf','doc','docx','xls','xlsx','ppt','pptx','txt','csv','zip','rar','7z']; + if (!$ext) $ext = 'bin'; + if (!in_array($ext, $allow)) resp_json(['ok' => false, 'message' => '不支持的文件类型: ' . $ext]); + $size = $file['size'] ?? 0; if ($size > 30 * 1024 * 1024) resp_json(['ok' => false, 'message' => '文件过大,限制30MB']); + $target = $uploadDir . DIRECTORY_SEPARATOR . ('up_' . date('Ymd_His') . '_' . substr(uniqid('', true), -6) . '.' . $ext); + if (!@move_uploaded_file($file['tmp_name'], $target)) resp_json(['ok' => false, 'message' => '保存失败']); + $rel = 'data/uploads/' . basename($target); + $mime = mime_content_type($target); + resp_json(['ok' => true, 'url' => $rel, 'name' => $name, 'mime' => $mime]); + } + default: resp_json(['ok' => false, 'message' => '未知操作']); +} + +// 将笔记文本转换为HTML(支持 Markdown 图片:![alt](url)),其余文本安全转义 +function render_note_content($text) { + if ($text === null) $text = ''; + $out = ''; + $last = 0; + while (true) { + $img = preg_match('/!\[([^\]]*)\]\(([^\)]+)\)/u', $text, $im, PREG_OFFSET_CAPTURE, $last) ? $im : null; + $lnk = preg_match('/(?=0 && ($lpos < 0 || $ipos <= $lpos)) ? $ipos : $lpos; + $out .= nl2br(htmlspecialchars(substr($text, $last, $nextPos - $last), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + if ($nextPos === $ipos) { + $alt = htmlspecialchars($img[1][0], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $urlAdj = adjust_url_for_export($img[2][0]); + $url = htmlspecialchars($urlAdj, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $out .= '' . $alt . ''; + $last = $ipos + strlen($img[0][0]); + } else { + $label = htmlspecialchars($lnk[1][0], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $urlRaw = $lnk[2][0]; + $urlAdj = adjust_url_for_export($urlRaw); + $urlEsc = htmlspecialchars($urlAdj, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + if (is_image_url($urlRaw)) { + $out .= '' . $label . ''; + } else { + $out .= '' . $label . ''; + } + $last = $lpos + strlen($lnk[0][0]); + } + } + $out .= nl2br(htmlspecialchars(substr($text, $last), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + return $out; +} + +function is_image_url($url) { + return preg_match('/\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i', $url) || preg_match('/^data:image\//i', $url); +} + +// 导出HTML时将相对路径调整为以导出文件为基准的相对路径 +// 例如:原始插入为 data/uploads/xxx.png,导出文件位于 data/exports/ 下, +// 浏览器在 exports 页面中引用 uploads 需使用 ../uploads/xxx.png +function adjust_url_for_export($url) { + if (!$url) return $url; + // 只处理本项目的相对路径 + if (preg_match('/^data\/uploads\//i', $url)) { + // 去掉可能的查询参数 + $parts = explode('?', $url, 2); + return '../uploads/' . substr($parts[0], strlen('data/uploads/')) . (isset($parts[1]) ? ('?' . $parts[1]) : ''); + } + return $url; +} + +// 提取笔记文本中所有 Markdown 链接/图片的 URL +function extract_note_urls($text) { + $urls = []; + if ($text === null) $text = ''; + // 图片 + if (preg_match_all('/!\[[^\]]*\]\(([^\)]+)\)/u', $text, $m1)) { + foreach ($m1[1] as $u) { $urls[] = $u; } + } + // 普通链接 + if (preg_match_all('/\[[^\]]+\]\(([^\)]+)\)/u', $text, $m2)) { + foreach ($m2[1] as $u) { $urls[] = $u; } + } + return $urls; +} + +// 仅清理 data/uploads 下未被任何笔记引用的文件 +function cleanup_unused_uploads($projects, $uploadDir) { + try { + $used = []; + foreach ($projects as $p) { + $notes = $p['notes'] ?? []; + foreach ($notes as $n) { + $urls = extract_note_urls($n['content'] ?? ''); + foreach ($urls as $u) { + // 只统计本地上传目录的相对路径 + $pos = strpos($u, 'data/uploads/'); + if ($pos !== false) { + $rel = substr($u, $pos); + // 去掉查询串 + $rel = explode('?', $rel)[0]; + $base = basename($rel); + if ($base) $used[$base] = true; + } + } + } + } + $deleted = 0; + if (is_dir($uploadDir)) { + $files = @scandir($uploadDir); + foreach ($files as $f) { + if ($f === '.' || $f === '..') continue; + if (!isset($used[$f])) { + $path = $uploadDir . DIRECTORY_SEPARATOR . $f; + if (is_file($path)) { @unlink($path); $deleted++; } + } + } + } + return [ 'deleted' => $deleted, 'kept' => count($used) ]; + } catch (Throwable $e) { + return [ 'deleted' => 0, 'error' => $e->getMessage() ]; + } +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..bbbbe77 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,144 @@ +:root { + --sidebar-width: 220px; + --bg: #f6f7fb; + --card-bg: #fff; + --border: #e5e7eb; + --text: #111827; + --muted: #6b7280; + --primary: #3b82f6; + --danger: #ef4444; + --success: #10b981; + --warning: #f59e0b; +} +* { box-sizing: border-box; } +body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); } +.app { display: flex; min-height: 100vh; } +.sidebar { width: var(--sidebar-width); background: #111827; color: #fff; padding: 16px; } +.sidebar h2 { margin-top: 0; font-size: 18px; } +.menu { list-style: none; padding: 0; margin: 0; } +.menu li { padding: 10px 12px; border-radius: 8px; cursor: pointer; } +.menu li.active, .menu li:hover { background: #374151; } +.main { flex: 1; padding: 16px; } +.header { display: flex; align-items: center; gap: 8px; justify-content: space-between; margin-bottom: 12px; } +.header-right { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; } +.header-left { display: flex; align-items: center; gap: 8px; } +.status-board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.column { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 10px; } +.column.drag-over { border-color: var(--primary); box-shadow: inset 0 0 0 2px rgba(59,130,246,0.15); background: #f8fbff; } +.column h3 { margin: 0 0 8px; font-size: 16px; } +.column .count { color: var(--muted); font-size: 12px; } +.project { background: #fff; border: 1px solid var(--border); border-radius: 8px; padding: 8px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; gap: 6px; } +.project.dragging { opacity: 0.6; } +.project .title { font-weight: 600; cursor: pointer; } +.project .actions { display: flex; gap: 6px; } +.btn { padding: 6px 10px; border: 1px solid var(--border); background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; } +.btn:hover { background:#f8fafc; box-shadow: 0 2px 8px rgba(0,0,0,0.06); } +a.btn, a.btn:link, a.btn:visited { color: var(--text); text-decoration: none; display:inline-flex; align-items:center; gap:6px; } +a.btn:hover { text-decoration: none; border-color: var(--primary); box-shadow: 0 2px 10px rgba(59,130,246,0.15); } +.btn.back-btn { position: relative; } +.btn.back-btn::before { content: "←"; color: var(--primary); margin-right: 6px; font-weight: 600; } + +/* 顶部操作区:让重命名输入框更长 */ +.project-actions { display:flex; gap:8px; align-items:center; margin-bottom:8px; flex-wrap: wrap; } +.project-actions #renameProjectInput { flex: 1; min-width: 380px; } + +/* 提醒横幅 */ +.remind-banner { display:flex; align-items:flex-start; gap:8px; background:#fff8db; border:1px solid #fde68a; color:#6b4e16; padding:8px 10px; border-radius:8px; font-size:13px; } +.remind-banner::before { content:"⚠"; font-weight:700; color:#b45309; } +.remind-banner .muted { color:#8b6b22; } +.btn.primary { background: var(--primary); color: #fff; border-color: var(--primary); } +.btn.danger { background: var(--danger); color: #fff; border-color: var(--danger); } +.btn.outline { background: #fff; color: var(--text); } +.input { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; } +.select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; background: #fff; } +.footer { margin-top: 12px; color: var(--muted); font-size: 12px; } +.chart-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px; margin-bottom: 12px; } +.chart-title { display:flex; align-items:center; justify-content:space-between; margin:0 0 8px; font-size:16px; } +.chart-msg { color: var(--muted); font-size: 12px; margin-top: 6px; } +.chart-container { position: relative; height: 240px; width: 100%; } +#monthlyChart { width: 100%; height: 100%; display: block; } + +/* 导出管理 */ +.export-item { display:flex; justify-content:space-between; align-items:center; padding:8px; border:1px solid var(--border); border-radius:8px; background:#fff; margin-bottom:8px; } +.export-info { font-size: 13px; color: var(--text); } +.export-actions { display:flex; gap:8px; } + +/* 模态框 */ +.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: none; align-items: center; justify-content: center; } +.modal { width: 640px; max-width: 92vw; background: #fff; border-radius: 10px; border: 1px solid var(--border); } +.modal-header { padding: 10px 12px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; } +.modal-body { padding: 12px; max-height: 68vh; overflow-y: auto; } +.modal-footer { padding: 10px 12px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; } +.note { border: 1px dashed var(--border); border-radius: 8px; padding: 8px; margin: 8px 0; } +.note .meta { color: var(--muted); font-size: 12px; } +.hidden { display: none; } + +/* 小工具类 */ +.muted-small { color: var(--muted); font-size: 12px; } +.muted { color: var(--muted); } +.ml-8 { margin-left: 8px; } +.notes-title { margin: 6px 0; } +.notes-add-bar { display:flex; gap:8px; align-items:center; margin-top:8px; } +.notes-add-bar textarea#newNoteInput { flex: 1; min-height: 120px; padding: 10px; font-size: 14px; line-height: 1.6; resize: vertical; } + +/* 笔记内容预览(支持图片) */ +.note-preview { margin-top: 6px; color: var(--muted); font-size: 13px; max-height: 280px; overflow: auto; padding: 6px; background: #fafafa; border: 1px solid var(--border); border-radius: 6px; } +.note-preview img { max-width: 100%; max-height: 240px; object-fit: contain; border-radius: 6px; margin-top: 6px; display: block; cursor: zoom-in; } +.note-preview a { color: var(--primary); text-decoration: none; word-break: break-all; } +.note-preview a:hover { text-decoration: underline; } + +/* 评论气泡样式(预览在上方,编辑框在下方) */ +.note-bubble { position: relative; background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.04); margin-bottom: 8px; } +.note-bubble::after { content: ""; position: absolute; left: 16px; top: -6px; width: 12px; height: 12px; background: #fff; border-left: 1px solid var(--border); border-top: 1px solid var(--border); transform: rotate(45deg); } +.note-bubble img { max-width: 100%; border-radius: 8px; margin-top: 6px; } + +/* Git 风格的笔记卡片 */ +.note-git { background:#f9fafb; border:1px solid var(--border); border-left:4px solid #1f2937; border-radius:8px; margin:10px 0; box-shadow:0 2px 6px rgba(0,0,0,0.02); } +.note-git-header { display:flex; align-items:center; gap:10px; color:var(--muted); font-size:12px; padding:8px 12px; border-bottom:1px dashed var(--border); } +.note-git-title { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-weight:600; color:var(--text); cursor:pointer; } +.note-git-title:hover { text-decoration: underline; } +.note-git-time { margin-left:auto; color:var(--muted); } +.note-git-body { padding:12px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; white-space: pre-wrap; word-break: break-word; } +.note-git-body img { max-width:100%; max-height: clamp(220px, 48vh, 420px); object-fit: contain; border-radius:6px; margin:6px 0; display:block; cursor: zoom-in; } + +/* 笔记卡片(头像+用户名+时间+操作) */ +.note-card { background:#fff; border:1px solid var(--border); border-radius:12px; padding:12px; margin:10px 0; box-shadow:0 2px 8px rgba(0,0,0,0.04); } +.note-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; } +.note-user { display:flex; align-items:center; gap:10px; } +.note-avatar { width:32px; height:32px; border-radius:50%; background:#e5e7eb; color:#374151; display:flex; align-items:center; justify-content:center; font-weight:600; } +.note-username { font-weight:600; } +.note-time { color:var(--muted); font-size:12px; } +.note-comment-btn { border:none; background:transparent; color:var(--muted); cursor:pointer; padding:4px 8px; border-radius:6px; } +.note-comment-btn:hover { background:#f3f4f6; color:var(--text); } +.note-footer { display:flex; align-items:center; justify-content:flex-end; margin-top:8px; } +.note-actions-left { display:flex; gap:14px; color:var(--muted); font-size:13px; } +.note-actions-left .action { cursor:pointer; user-select:none; } +.note-actions-left .action:hover { color:var(--text); } +.note-actions-right { display:flex; gap:8px; } + +/* 图片浮窗(Lightbox) */ +.img-lightbox-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; align-items: center; justify-content: center; z-index: 10000; } +.img-lightbox-content { position: relative; max-width: 92vw; max-height: 92vh; } +.img-lightbox-content img { max-width: 92vw; max-height: 92vh; border-radius: 8px; box-shadow: 0 12px 30px rgba(0,0,0,0.35); background: #fff; } +.img-lightbox-close { position: absolute; top: -10px; right: -10px; width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.2); } + +/* 自适应/响应式:根据视口宽度优化布局,在浏览器缩放时也能更好适配 */ +@media (max-width: 1280px) { + .status-board { grid-template-columns: repeat(3, 1fr); } +} +@media (max-width: 960px) { + .app { display: block; } + .main { padding: 12px; } + .status-board { grid-template-columns: repeat(2, 1fr); gap: 10px; } + .column { padding: 8px; } + .chart-container { height: clamp(180px, 28vh, 240px); } + .note-preview { max-height: clamp(200px, 34vh, 280px); } +} +@media (max-width: 640px) { + .header { flex-wrap: wrap; gap: 6px; } + .header-right { width: 100%; justify-content: flex-start; gap: 6px; } + .status-board { grid-template-columns: 1fr; } + .chart-container { height: clamp(160px, 32vh, 220px); } + .modal { width: 100%; max-width: 96vw; } + .note-preview img { max-height: 200px; } +} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..0b9a5a5 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,689 @@ +// ===== 从 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 = `

${st} (${filtered.length})

`; + // 列拖拽目标事件 + 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![图片](${up.url})\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 图片:![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, '
'); + if (nextRel === imgPos) { + const alt = escapeHtml(imgM[1] || ''); + const url = escapeHtml(imgM[2] || ''); + out += `${alt}`; + pos += imgPos + imgM[0].length; + } else { + const label = escapeHtml(linkM[1] || ''); + const urlRaw = linkM[2] || ''; + const url = escapeHtml(urlRaw); + if (isImageUrl(urlRaw)) { + out += `${label}`; + } else { + out += `${label}`; + } + pos += linkPos + linkM[0].length; + } + } + out += escapeHtml(text.slice(pos)).replace(/\n/g, '
'); + return out; +} +function escapeHtml(s) { return String(s).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![${fname}](${url})\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![${up.name || f.name || '图片'}](${up.url})\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 = '
暂无匹配的导出文件
'; 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); }); +}); diff --git a/assets/js/project.js b/assets/js/project.js new file mode 100644 index 0000000..dcffd5e --- /dev/null +++ b/assets/js/project.js @@ -0,0 +1,456 @@ +// 项目详情页脚本(不使用悬浮弹窗) +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,'&').replace(//g,'>').replace(/"/g,'"'); } +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, '
'); + if (nextRel === imgPos) { + const alt = escapeHtml(imgM[1] || ''); + const url = escapeHtml(imgM[2] || ''); + out += `${alt}`; + pos += imgPos + imgM[0].length; + } else { + const label = escapeHtml(linkM[1] || ''); + const urlRaw = linkM[2] || ''; + const url = escapeHtml(urlRaw); + if (isImageUrl(urlRaw)) { + out += `${label}`; + } else { + out += `${label}`; + } + pos += linkPos + linkM[0].length; + } + } + out += escapeHtml(text.slice(pos)).replace(/\n/g, '
'); + 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(); +}); diff --git a/data/ai_reminder.json b/data/ai_reminder.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/data/ai_reminder.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/data/ark_api_key.txt b/data/ark_api_key.txt new file mode 100644 index 0000000..44021ba --- /dev/null +++ b/data/ark_api_key.txt @@ -0,0 +1 @@ +29db635c-fc90-4768-be88-f3f283f0b40f \ No newline at end of file diff --git a/data/projects.json b/data/projects.json new file mode 100644 index 0000000..d077235 --- /dev/null +++ b/data/projects.json @@ -0,0 +1,223 @@ +[ + { + "id": "69113e14a4a6d3.08228982", + "name": "202-20251023-001 华贝四通道", + "status": "完成", + "notes": [ + { + "id": "69113e38354b13.64838711", + "content": "电路图和程序已做完", + "created_at": "2025-11-10 02:22:00", + "updated_at": "2025-11-10 02:22:00" + }, + { + "id": "69114b28dc5e70.82623136", + "content": "物料异常504-0315-074变更为504-0315-066\n![image.png](data\/uploads\/up_20251110_032334_977265.png)替换为![image.png](data\/uploads\/up_20251110_032321_242501.png)", + "created_at": "2025-11-10 03:17:12", + "updated_at": "2025-11-11 15:45:41" + }, + { + "id": "6911588ec744b3.88497779", + "content": "开始装配", + "created_at": "2025-11-10 04:14:22", + "updated_at": "2025-11-10 04:14:22" + }, + { + "id": "6915274037db45.73502181", + "content": "预计今天调试", + "created_at": "2025-11-13 08:33:04", + "updated_at": "2025-11-13 08:33:04" + }, + { + "id": "691536b38f65c6.23540535", + "content": "点位偏移 Y16 Y17 换到Y60 Y61 因为主模块缺失两个点位", + "created_at": "2025-11-13 09:38:59", + "updated_at": "2025-11-13 09:38:59" + }, + { + "id": "69159aaa01a710.30202196", + "content": "调试阶段", + "created_at": "2025-11-13 16:45:30", + "updated_at": "2025-11-13 16:45:30" + } + ], + "created_at": "2025-11-10 02:21:24", + "updated_at": "2025-11-17 08:49:54" + }, + { + "id": "6911506e752fc7.70298044", + "name": "明通八方", + "status": "异常", + "notes": [ + { + "id": "6911508eec0de2.55005459", + "content": "出货第一台为标准机双电磁阀程序,第二台为双电磁阀但是下压气缸使用双头的,拍下急停先给仪器复位信号等待三秒在复位设备", + "created_at": "2025-11-10 03:40:14", + "updated_at": "2025-11-13 08:34:30" + }, + { + "id": "691151b4e058c7.57343764", + "content": "![image.png](data\/uploads\/up_20251110_034506_014266.png)", + "created_at": "2025-11-10 03:45:08", + "updated_at": "2025-11-10 03:45:15" + } + ], + "created_at": "2025-11-10 03:39:42", + "updated_at": "2025-11-10 03:39:44" + }, + { + "id": "6911521a348912.72415509", + "name": "标准机", + "status": "完成", + "notes": [ + { + "id": "69115232056f85.50079503", + "content": "修改测堵逻辑,延时增加0.8s", + "created_at": "2025-11-10 03:47:14", + "updated_at": "2025-11-10 03:47:16" + } + ], + "created_at": "2025-11-10 03:46:50", + "updated_at": "2025-11-10 03:47:22" + }, + { + "id": "6911571a3078f8.05270717", + "name": "202-20250312-012 欧荘", + "status": "完成", + "notes": [ + { + "id": "6911576946e9d5.87043878", + "content": "修改需求:点击屏幕可以启动治具", + "created_at": "2025-11-10 04:09:29", + "updated_at": "2025-11-10 04:10:03" + }, + { + "id": "691158acb74cc7.13001871", + "content": "已上门处理完", + "created_at": "2025-11-10 04:14:52", + "updated_at": "2025-11-10 04:14:52" + } + ], + "created_at": "2025-10-10 04:08:10", + "updated_at": "2025-10-10 04:10:17" + }, + { + "id": "691286595e5780.27379948", + "name": "耕得电子 202-20250728-001", + "status": "完成", + "notes": [ + { + "id": "69128ccde5f2a9.58594282", + "content": "复制 离线式双工位双仪器电缸版的程序", + "created_at": "2025-11-11 02:09:33", + "updated_at": "2025-11-11 02:09:33" + } + ], + "created_at": "2025-10-11 01:42:01", + "updated_at": "2025-10-11 01:42:04" + }, + { + "id": "691286678014a3.57619340", + "name": "华贝-202-20250911-004", + "status": "完成", + "notes": [ + { + "id": "69128ca78d7a25.88122586", + "content": "经过更新设备使用两套允许逻辑通过X37这个点进行切换:第一套默认,第二套测试完成后才堵测堵", + "created_at": "2025-11-11 02:08:55", + "updated_at": "2025-11-11 02:08:55" + } + ], + "created_at": "2025-10-11 01:42:15", + "updated_at": "2025-10-11 01:42:17" + }, + { + "id": "6912866f593012.04320815", + "name": "科伦特", + "status": "完成", + "notes": [ + { + "id": "69128ce3952f86.45359449", + "content": "按双按钮进行下压,启动仪器方式是人工手动按仪器进行启动\n测试完成是人工旋转复位按钮进行复位,仪器测试完成不自动复位", + "created_at": "2025-11-11 02:09:55", + "updated_at": "2025-11-11 02:09:55" + } + ], + "created_at": "2025-10-11 01:42:23", + "updated_at": "2025-10-11 01:42:41" + }, + { + "id": "6912867755fe73.35852449", + "name": "明美加继电器", + "status": "完成", + "notes": [ + { + "id": "69128c4e45c7e0.63898276", + "content": "客户增加需求,启动仪器的时候同时输出一个信号给对方的扫码枪扫码,经过沟通决定使用继电器来做信号转接,流程为:下压--测堵--启动仪器--吸合继电器--测试完成--断开继电器--测堵回--下压回", + "created_at": "2025-11-11 02:07:26", + "updated_at": "2025-11-11 02:07:26" + } + ], + "created_at": "2025-10-11 01:42:31", + "updated_at": "2025-10-11 01:42:40" + }, + { + "id": "6912867ca70651.00182168", + "name": "通力流量双工位", + "status": "完成", + "notes": [ + { + "id": "691286991418c8.32008067", + "content": "单工位启动的时候条件是:任意物料感应到位按下启动给信号让上位机扫码,扫码完成开始启动; \n双工位启动的时候条件是:两个物料感应到位按下启动给信号让上位机扫码,扫码完成开始启动", + "created_at": "2025-10-11 01:43:05", + "updated_at": "2025-10-11 01:43:15" + } + ], + "created_at": "2025-10-11 01:42:36", + "updated_at": "2025-10-11 01:42:38" + }, + { + "id": "6912ad95f08674.37367476", + "name": "老F型改造为新版F型", + "status": "待做", + "notes": [ + { + "id": "6912addd4e4f15.54079500", + "content": "PLC换成领控,加入EX14-A常开光电,加一个235通讯线", + "created_at": "2025-11-11 11:30:37", + "updated_at": "2025-11-11 11:30:37" + }, + { + "id": "6915275b5f6972.26468336", + "content": "购买物料中", + "created_at": "2025-11-13 08:33:31", + "updated_at": "2025-11-13 08:33:31" + } + ], + "created_at": "2025-11-11 11:29:25", + "updated_at": "2025-11-11 11:29:25" + }, + { + "id": "6916c378a20be5.14702126", + "name": "售后处理", + "status": "进行", + "notes": [ + { + "id": "6916c3bfd6a367.53168084", + "content": "考斯泰售后 测试记录不连续;比如1个小时内测了100个产品实际记录的只有30个,原因是排气时间不够充足导致触摸屏抓取不到", + "created_at": "2025-11-14 13:53:03", + "updated_at": "2025-11-14 13:53:03" + } + ], + "created_at": "2025-11-14 13:51:52", + "updated_at": "2025-11-14 13:51:54" + }, + { + "id": "691a7157217782.74284323", + "name": "F型双工位电缸版单仪器20251115", + "status": "完成", + "notes": [], + "created_at": "2025-11-17 08:50:31", + "updated_at": "2025-11-17 08:50:47" + } +] \ No newline at end of file diff --git a/data/uploads/up_20251110_032321_242501.png b/data/uploads/up_20251110_032321_242501.png new file mode 100644 index 0000000..866ede2 Binary files /dev/null and b/data/uploads/up_20251110_032321_242501.png differ diff --git a/data/uploads/up_20251110_032334_977265.png b/data/uploads/up_20251110_032334_977265.png new file mode 100644 index 0000000..0af33ad Binary files /dev/null and b/data/uploads/up_20251110_032334_977265.png differ diff --git a/data/uploads/up_20251110_034506_014266.png b/data/uploads/up_20251110_034506_014266.png new file mode 100644 index 0000000..9afa5e8 Binary files /dev/null and b/data/uploads/up_20251110_034506_014266.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..86aeca2 --- /dev/null +++ b/index.html @@ -0,0 +1,39 @@ + + + + + 恭喜,站点创建成功! + + + +
+

恭喜, 站点创建成功!

+

这是默认index.html,本页面由系统自动生成

+ +
+ + \ No newline at end of file diff --git a/index.php b/index.php new file mode 100644 index 0000000..bd09179 --- /dev/null +++ b/index.php @@ -0,0 +1,136 @@ + + + + + + + 登录 - 笔记管理 + + + + +
+ +
+ + + + + + + + + + 项目工作进度与笔记管理 + + + +
+ +
+
+
+ 项目列表 · 工作进度看板 +
+
+ + + + + + + + +
+
+ +
+ +
+ +
+

月份曲线图 展示最近12个月各状态项目数量

+
+ +
+
+
+ +
+

导出的HTML管理 + + + + + + +

+
+
+
+
+
+ + + + + + + + diff --git a/project.php b/project.php new file mode 100644 index 0000000..4b2402b --- /dev/null +++ b/project.php @@ -0,0 +1,79 @@ + + + + + + + 项目详情 + + + + + +
+
+
+ 返回列表 + 项目详情 + +
+
+ +
+
+ +
+ + + + + + + +
+ +
+ +
+ +
+

笔记

+
+
+ + + + +
+
+ +
+
+ + + +