$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() ]; } }