初始化版本
This commit is contained in:
510
api.php
Normal file
510
api.php
Normal file
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
session_start();
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; }
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . 'data';
|
||||
$exportDir = $dataDir . DIRECTORY_SEPARATOR . 'exports';
|
||||
$uploadDir = $dataDir . DIRECTORY_SEPARATOR . 'uploads';
|
||||
$dataFile = $dataDir . DIRECTORY_SEPARATOR . 'projects.json';
|
||||
// 存储AI润色结果的JSON文件
|
||||
$aiTextFile = $dataDir . DIRECTORY_SEPARATOR . 'ai_reminder.json';
|
||||
|
||||
if (!is_dir($dataDir)) { @mkdir($dataDir, 0777, true); }
|
||||
if (!is_dir($exportDir)) { @mkdir($exportDir, 0777, true); }
|
||||
if (!is_dir($uploadDir)) { @mkdir($uploadDir, 0777, true); }
|
||||
if (!file_exists($dataFile)) { file_put_contents($dataFile, json_encode([])); }
|
||||
if (!file_exists($aiTextFile)) { file_put_contents($aiTextFile, json_encode(new stdClass())); }
|
||||
|
||||
function readJson($file) {
|
||||
$raw = @file_get_contents($file);
|
||||
$arr = json_decode($raw, true);
|
||||
if (!is_array($arr)) $arr = [];
|
||||
return $arr;
|
||||
}
|
||||
function writeJson($file, $arr) {
|
||||
$json = json_encode($arr, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
$fp = fopen($file, 'w');
|
||||
if ($fp) {
|
||||
// 简单写锁
|
||||
if (flock($fp, LOCK_EX)) {
|
||||
fwrite($fp, $json);
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
}
|
||||
fclose($fp);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function uid() { return uniqid('', true); }
|
||||
function nowStr() { return date('Y-m-d H:i:s'); }
|
||||
|
||||
function body_json() {
|
||||
$raw = file_get_contents('php://input');
|
||||
$json = json_decode($raw, true);
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
function resp_json($data) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function find_index_by_id(&$arr, $id) {
|
||||
foreach ($arr as $i => $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 = "<!DOCTYPE html><html lang=\"zh-CN\"><meta charset=\"utf-8\"><title>" . htmlspecialchars($p['name']) . " - 导出</title><style>body{font-family:Arial,Microsoft YaHei;padding:16px}h1{margin-top:0} .note{border:1px dashed #ccc; padding:8px; border-radius:8px; margin:8px 0}</style><body>";
|
||||
$html .= '<h1>' . htmlspecialchars($p['name']) . '</h1>';
|
||||
$html .= '<p>状态:' . htmlspecialchars($p['status']) . '</p>';
|
||||
$html .= '<h2>笔记</h2>';
|
||||
foreach (($p['notes'] ?? []) as $n) {
|
||||
$html .= '<div class="note"><div>' . render_note_content($n['content'] ?? '') . '</div>';
|
||||
$html .= '<div style="color:#666;font-size:12px">创建:' . htmlspecialchars($n['created_at'] ?? '') . ' 更新:' . htmlspecialchars($n['updated_at'] ?? '') . '</div></div>';
|
||||
}
|
||||
$html .= '</body></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('/(?<!\!)\[([^\]]+)\]\(([^\)]+)\)/u', $text, $lm, PREG_OFFSET_CAPTURE, $last) ? $lm : null;
|
||||
$ipos = $img ? $img[0][1] : -1;
|
||||
$lpos = $lnk ? $lnk[0][1] : -1;
|
||||
if ($ipos < 0 && $lpos < 0) break;
|
||||
$nextPos = ($ipos >=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 .= '<img src="' . $url . '" alt="' . $alt . '" style="max-width:100%">';
|
||||
$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 .= '<img src="' . $urlEsc . '" alt="' . $label . '" style="max-width:100%">';
|
||||
} else {
|
||||
$out .= '<a href="' . $urlEsc . '" target="_blank" rel="noopener noreferrer">' . $label . '</a>';
|
||||
}
|
||||
$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() ];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user