$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 图片:),其余文本安全转义
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 .= '
';
$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 .= '
';
} 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() ];
}
}