初始化版本
This commit is contained in:
7
404.html
Normal file
7
404.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<html>
|
||||
<head><title>404 Not Found</title></head>
|
||||
<body>
|
||||
<center><h1>404 Not Found</h1></center>
|
||||
<hr><center>nginx</center>
|
||||
</body>
|
||||
</html>
|
||||
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() ];
|
||||
}
|
||||
}
|
||||
144
assets/css/style.css
Normal file
144
assets/css/style.css
Normal file
@@ -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; }
|
||||
}
|
||||
689
assets/js/app.js
Normal file
689
assets/js/app.js
Normal file
@@ -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 = `<h3>${st} <span class="count">(${filtered.length})</span></h3>`;
|
||||
// 列拖拽目标事件
|
||||
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\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 图片:)
|
||||
function noteTextToHtml(text) {
|
||||
if (!text) return '';
|
||||
const imgReg = /!\[([^\]]*)\]\(([^\)]+)\)/u;
|
||||
const linkReg = /\[([^\]]+)\]\(([^\)]+)\)/u; // 链接(图片将由 imgReg 优先匹配)
|
||||
let out = '';
|
||||
let pos = 0;
|
||||
while (true) {
|
||||
const imgM = imgReg.exec(text.slice(pos));
|
||||
const linkM = linkReg.exec(text.slice(pos));
|
||||
const imgPos = imgM ? imgM.index : -1;
|
||||
const linkPos = linkM ? linkM.index : -1;
|
||||
if (imgPos < 0 && linkPos < 0) break;
|
||||
const nextRel = (imgPos >=0 && (linkPos < 0 || imgPos <= linkPos)) ? imgPos : linkPos;
|
||||
out += escapeHtml(text.slice(pos, pos + nextRel)).replace(/\n/g, '<br>');
|
||||
if (nextRel === imgPos) {
|
||||
const alt = escapeHtml(imgM[1] || '');
|
||||
const url = escapeHtml(imgM[2] || '');
|
||||
out += `<img src="${url}" alt="${alt}">`;
|
||||
pos += imgPos + imgM[0].length;
|
||||
} else {
|
||||
const label = escapeHtml(linkM[1] || '');
|
||||
const urlRaw = linkM[2] || '';
|
||||
const url = escapeHtml(urlRaw);
|
||||
if (isImageUrl(urlRaw)) {
|
||||
out += `<img src="${url}" alt="${label}">`;
|
||||
} else {
|
||||
out += `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
}
|
||||
pos += linkPos + linkM[0].length;
|
||||
}
|
||||
}
|
||||
out += escapeHtml(text.slice(pos)).replace(/\n/g, '<br>');
|
||||
return out;
|
||||
}
|
||||
function escapeHtml(s) { return String(s).replace(/&/g,'&').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\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\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 = '<div class="muted">暂无匹配的导出文件</div>'; 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); });
|
||||
});
|
||||
456
assets/js/project.js
Normal file
456
assets/js/project.js
Normal file
@@ -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,'>').replace(/"/g,'"'); }
|
||||
function isImageUrl(url){ return /\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i.test(url) || /^data:image\//i.test(url); }
|
||||
|
||||
// 将笔记文本转换为HTML(支持 Markdown 图片:)
|
||||
function noteTextToHtml(text) {
|
||||
if (!text) return '';
|
||||
const imgReg = /!\[([^\]]*)\]\(([^\)]+)\)/u;
|
||||
const linkReg = /\[([^\]]+)\]\(([^\)]+)\)/u; // 链接(图片将由 imgReg 优先匹配)
|
||||
let out = '';
|
||||
let pos = 0;
|
||||
while (true) {
|
||||
const imgM = imgReg.exec(text.slice(pos));
|
||||
const linkM = linkReg.exec(text.slice(pos));
|
||||
const imgPos = imgM ? imgM.index : -1;
|
||||
const linkPos = linkM ? linkM.index : -1;
|
||||
if (imgPos < 0 && linkPos < 0) break;
|
||||
const nextRel = (imgPos >=0 && (linkPos < 0 || imgPos <= linkPos)) ? imgPos : linkPos;
|
||||
out += escapeHtml(text.slice(pos, pos + nextRel)).replace(/\n/g, '<br>');
|
||||
if (nextRel === imgPos) {
|
||||
const alt = escapeHtml(imgM[1] || '');
|
||||
const url = escapeHtml(imgM[2] || '');
|
||||
out += `<img src="${url}" alt="${alt}">`;
|
||||
pos += imgPos + imgM[0].length;
|
||||
} else {
|
||||
const label = escapeHtml(linkM[1] || '');
|
||||
const urlRaw = linkM[2] || '';
|
||||
const url = escapeHtml(urlRaw);
|
||||
if (isImageUrl(urlRaw)) {
|
||||
out += `<img src="${url}" alt="${label}">`;
|
||||
} else {
|
||||
out += `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
}
|
||||
pos += linkPos + linkM[0].length;
|
||||
}
|
||||
}
|
||||
out += escapeHtml(text.slice(pos)).replace(/\n/g, '<br>');
|
||||
return out;
|
||||
}
|
||||
|
||||
function insertAtCursor(ta, text) {
|
||||
const start = ta.selectionStart ?? ta.value.length; const end = ta.selectionEnd ?? start;
|
||||
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = start + text.length;
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
function isImageFile(file) {
|
||||
const t = (file?.type || '').toLowerCase();
|
||||
if (t.startsWith('image/')) return true;
|
||||
const name = file?.name || '';
|
||||
return /\.(png|jpg|jpeg|gif|webp)$/i.test(name);
|
||||
}
|
||||
|
||||
function enhancePreview(previewEl) {
|
||||
previewEl.querySelectorAll('img').forEach(img => {
|
||||
img.addEventListener('click', () => { openImageLightbox(img.src, img.alt || ''); });
|
||||
});
|
||||
}
|
||||
|
||||
// ====== 图片浮窗(Lightbox) ======
|
||||
let _imgLightboxEl = null;
|
||||
function ensureImageLightbox() {
|
||||
if (_imgLightboxEl) return _imgLightboxEl;
|
||||
const mask = document.createElement('div');
|
||||
mask.className = 'img-lightbox-mask';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'img-lightbox-content';
|
||||
const img = document.createElement('img');
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'img-lightbox-close'; closeBtn.textContent = '×';
|
||||
content.appendChild(img); content.appendChild(closeBtn);
|
||||
mask.appendChild(content);
|
||||
document.body.appendChild(mask);
|
||||
const close = () => { mask.style.display = 'none'; document.body.style.overflow = ''; document.removeEventListener('keydown', escHandler); };
|
||||
const escHandler = (e) => { if (e.key === 'Escape') close(); };
|
||||
mask.addEventListener('click', close);
|
||||
content.addEventListener('click', (e) => e.stopPropagation());
|
||||
closeBtn.addEventListener('click', close);
|
||||
_imgLightboxEl = mask;
|
||||
return _imgLightboxEl;
|
||||
}
|
||||
function openImageLightbox(src, alt) {
|
||||
const el = ensureImageLightbox();
|
||||
const img = el.querySelector('img');
|
||||
img.src = src; img.alt = alt || '';
|
||||
el.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
const escHandler = (e) => { if (e.key === 'Escape') { el.style.display='none'; document.body.style.overflow=''; document.removeEventListener('keydown', escHandler); } };
|
||||
document.addEventListener('keydown', escHandler);
|
||||
}
|
||||
|
||||
async function uploadFile(file) {
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
return api('api.php?action=upload_file', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
async function loadProject() {
|
||||
const id = window.PAGE_PROJECT_ID || '';
|
||||
if (!id) { document.getElementById('pageMsg').textContent = '缺少项目ID'; return; }
|
||||
const data = await api('api.php?action=get_project&id=' + encodeURIComponent(id));
|
||||
if (data && data.ok) {
|
||||
CurrentProject = data.project; CurrentProjectId = CurrentProject.id;
|
||||
document.getElementById('pageProjectTitle').textContent = CurrentProject.name;
|
||||
document.getElementById('pageProjectStatus').textContent = '· 当前状态:' + CurrentProject.status;
|
||||
document.getElementById('renameProjectInput').value = CurrentProject.name;
|
||||
document.getElementById('statusSelect').value = CurrentProject.status;
|
||||
// ETA 输入显示控制(仅进行中显示)
|
||||
const etaInput = document.getElementById('etaInput');
|
||||
if (etaInput) {
|
||||
const key = 'project_eta_' + CurrentProjectId;
|
||||
const saved = localStorage.getItem(key) || '';
|
||||
etaInput.style.display = (CurrentProject.status === '进行') ? '' : 'none';
|
||||
if (saved) etaInput.value = saved;
|
||||
}
|
||||
updateProjectReminder();
|
||||
renderNotes(CurrentProject.notes || []);
|
||||
} else {
|
||||
document.getElementById('pageMsg').textContent = (data && data.message) ? data.message : '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(str) {
|
||||
if (!str) return '';
|
||||
const d = new Date(str.replace(/-/g,'/')); // iOS 兼容
|
||||
if (isNaN(d.getTime())) return str;
|
||||
const diff = Date.now() - d.getTime();
|
||||
const sec = Math.floor(diff/1000); if (sec < 60) return sec + '秒前';
|
||||
const min = Math.floor(sec/60); if (min < 60) return min + '分钟前';
|
||||
const hour = Math.floor(min/60); if (hour < 24) return hour + '小时前';
|
||||
const day = Math.floor(hour/24); if (day < 30) return day + '天前';
|
||||
const month = Math.floor(day/30); if (month < 12) return month + '个月前';
|
||||
const year = Math.floor(month/12); return year + '年前';
|
||||
}
|
||||
function durationSince(str) {
|
||||
if (!str) return '';
|
||||
const d = new Date(str.replace(/-/g,'/'));
|
||||
if (isNaN(d.getTime())) return '';
|
||||
let ms = Date.now() - d.getTime();
|
||||
if (ms < 0) ms = 0;
|
||||
const days = Math.floor(ms / (24*3600*1000));
|
||||
ms -= days * 24*3600*1000;
|
||||
const hours = Math.floor(ms / (3600*1000));
|
||||
ms -= hours * 3600*1000;
|
||||
const mins = Math.floor(ms / (60*1000));
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(days + '天');
|
||||
if (hours > 0) parts.push(hours + '小时');
|
||||
if (days === 0 && mins > 0) parts.push(mins + '分钟');
|
||||
return parts.join('') || '刚刚';
|
||||
}
|
||||
|
||||
function updateProjectReminder() {
|
||||
try {
|
||||
const box = document.getElementById('projectRemind'); if (!box || !CurrentProject) return;
|
||||
const p = CurrentProject;
|
||||
let msg = '';
|
||||
if (p.status === '异常') {
|
||||
const base = p.updated_at || p.created_at || '';
|
||||
msg = `异常状态已持续:${durationSince(base)}。请尽快处理。`;
|
||||
} else if (p.status === '待做') {
|
||||
const base = p.created_at || '';
|
||||
msg = `项目创建至今已等待:${durationSince(base)}。建议尽快开始。`;
|
||||
} else if (p.status === '进行') {
|
||||
const base = p.created_at || p.updated_at || '';
|
||||
const howlong = durationSince(base);
|
||||
const key = 'project_eta_' + p.id;
|
||||
const eta = localStorage.getItem(key) || '';
|
||||
if (eta) {
|
||||
const ed = new Date(eta + 'T00:00:00');
|
||||
const now = new Date();
|
||||
let diff = ed.getTime() - now.getTime();
|
||||
const daysLeft = Math.ceil(diff / (24*3600*1000));
|
||||
const tail = daysLeft >= 0 ? `剩余约 ${daysLeft} 天` : `已超期 ${Math.abs(daysLeft)} 天`;
|
||||
msg = `进行中:已用时 ${howlong};计划完成:${eta}(${tail})。`;
|
||||
} else {
|
||||
msg = `进行中:已用时 ${howlong}。可设置“计划完成日期”以显示剩余天数。`;
|
||||
}
|
||||
} else if (p.status === '完成') {
|
||||
const base = p.updated_at || p.created_at || '';
|
||||
msg = `已完成(${timeAgo(base)})。`;
|
||||
}
|
||||
// 原始提醒文案(用于 AI 的原始输入)
|
||||
box.textContent = msg;
|
||||
box.setAttribute('data-raw', msg);
|
||||
// 尝试从后端获取已保存的AI润色文本(持久化在服务器JSON)
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await api('api.php?action=get_ai_reminder&id=' + encodeURIComponent(CurrentProjectId));
|
||||
if (resp && resp.ok && resp.text) {
|
||||
box.textContent = resp.text;
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ====== AI 自动润色提醒 ======
|
||||
async function aiEnhanceReminderOnce() {
|
||||
try {
|
||||
const box = document.getElementById('projectRemind');
|
||||
if (!box || !CurrentProjectId) return;
|
||||
const raw = (box.getAttribute('data-raw') || box.textContent || '').trim();
|
||||
if (!raw) return;
|
||||
// 进度提示:结合笔记进行润色
|
||||
box.textContent = 'AI正在结合笔记进行润色…';
|
||||
const lastKey = 'ai_last_ts_' + CurrentProjectId;
|
||||
const resp = await api('api.php?action=ai_enrich_reminder', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ raw: raw, project: { id: CurrentProjectId, name: CurrentProject?.name, status: CurrentProject?.status }, include_notes: true })
|
||||
});
|
||||
if (resp && resp.ok && resp.text) {
|
||||
box.textContent = resp.text;
|
||||
try { localStorage.setItem(lastKey, String(Date.now())); } catch (e) {}
|
||||
}
|
||||
} catch (e) { /* 静默失败 */ }
|
||||
}
|
||||
|
||||
// 已取消自动AI润色,改为手动点击按钮触发
|
||||
function firstLetter(name){ return (name || '记').trim().slice(0,1).toUpperCase(); }
|
||||
|
||||
function renderNotes(notes) {
|
||||
const list = document.getElementById('notesList');
|
||||
list.innerHTML = '';
|
||||
notes.forEach(n => {
|
||||
// Git风格卡片容器
|
||||
const card = document.createElement('div'); card.className = 'note-git';
|
||||
const header = document.createElement('div'); header.className = 'note-git-header';
|
||||
const titleEl = document.createElement('span'); titleEl.className = 'note-git-title'; titleEl.textContent = '笔记';
|
||||
titleEl.title = '点击以展开/收起编辑';
|
||||
const timeEl = document.createElement('span'); timeEl.className = 'note-git-time'; timeEl.textContent = timeAgo(n.created_at || '') || (n.created_at || '');
|
||||
header.appendChild(titleEl); header.appendChild(timeEl);
|
||||
// 预览(Git风格正文)
|
||||
const preview = document.createElement('div'); preview.className = 'note-git-body';
|
||||
const ta = document.createElement('textarea'); ta.className = 'input'; ta.style.width = '100%'; ta.style.minHeight = '80px'; ta.value = n.content || '';
|
||||
preview.innerHTML = noteTextToHtml(ta.value || '');
|
||||
enhancePreview(preview);
|
||||
// 编辑区域(默认展开)
|
||||
const editWrap = document.createElement('div');
|
||||
editWrap.style.marginTop = '6px';
|
||||
editWrap.appendChild(ta);
|
||||
editWrap.classList.add('hidden');
|
||||
|
||||
// 底部操作:左侧图标,右侧保存/删除/添加图片
|
||||
const footer = document.createElement('div'); footer.className = 'note-footer';
|
||||
const actionsRight = document.createElement('div'); actionsRight.className = 'note-actions-right';
|
||||
const save = document.createElement('button'); save.className = 'btn'; save.textContent = '保存修改'; save.onclick = () => updateNote(n.id, ta.value);
|
||||
const del = document.createElement('button'); del.className = 'btn danger'; del.textContent = '删除'; del.onclick = () => deleteNote(n.id);
|
||||
const addImgBtn = document.createElement('button'); addImgBtn.className = 'btn'; addImgBtn.textContent = '添加图片';
|
||||
const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.className = 'hidden';
|
||||
addImgBtn.onclick = () => fileInput.click();
|
||||
fileInput.onchange = async () => {
|
||||
const f = fileInput.files?.[0]; if (!f) return; if (!isImageFile(f)) { alert('请选择图片文件'); return; }
|
||||
const up = await uploadFile(f);
|
||||
if (up && up.ok && up.url) {
|
||||
insertAtCursor(ta, ``);
|
||||
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, ``);
|
||||
} else {
|
||||
alert(up?.message || '上传失败');
|
||||
}
|
||||
imgInput.value = '';
|
||||
});
|
||||
}
|
||||
// 加载项目
|
||||
loadProject();
|
||||
});
|
||||
1
data/ai_reminder.json
Normal file
1
data/ai_reminder.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
data/ark_api_key.txt
Normal file
1
data/ark_api_key.txt
Normal file
@@ -0,0 +1 @@
|
||||
29db635c-fc90-4768-be88-f3f283f0b40f
|
||||
223
data/projects.json
Normal file
223
data/projects.json
Normal file
@@ -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替换为",
|
||||
"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": "",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
BIN
data/uploads/up_20251110_032321_242501.png
Normal file
BIN
data/uploads/up_20251110_032321_242501.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
data/uploads/up_20251110_032334_977265.png
Normal file
BIN
data/uploads/up_20251110_032334_977265.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
data/uploads/up_20251110_034506_014266.png
Normal file
BIN
data/uploads/up_20251110_034506_014266.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
39
index.html
Normal file
39
index.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>恭喜,站点创建成功!</title>
|
||||
<style>
|
||||
.container {
|
||||
width: 60%;
|
||||
margin: 10% auto 0;
|
||||
background-color: #f0f0f0;
|
||||
padding: 2% 5%;
|
||||
border-radius: 10px
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
line-height: 2.3
|
||||
}
|
||||
|
||||
a {
|
||||
color: #20a53a
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>恭喜, 站点创建成功!</h1>
|
||||
<h3>这是默认index.html,本页面由系统自动生成</h3>
|
||||
<ul>
|
||||
<li>本页面在FTP根目录下的index.html</li>
|
||||
<li>您可以修改、删除或覆盖本页面</li>
|
||||
<li>FTP相关信息,请到“面板系统后台 > FTP” 查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
136
index.php
Normal file
136
index.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
session_start();
|
||||
if (!isset($_SESSION['auth']) || !$_SESSION['auth']) {
|
||||
// 未登录:显示登录页并退出
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 - 笔记管理</title>
|
||||
<link rel="stylesheet" href="assets/css/style.css" />
|
||||
<style>
|
||||
.login-wrap { min-height: 100vh; display:flex; align-items:center; justify-content:center; background:#f7f8fa; }
|
||||
.login-card { width: 360px; padding: 24px; border:1px solid var(--border); border-radius:12px; background:#fff; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
|
||||
.login-card h1 { font-size: 18px; margin: 0 0 16px; }
|
||||
.login-card .field { margin: 12px 0; }
|
||||
.login-card input { width: 100%; padding: 10px 12px; border:1px solid var(--border); border-radius:8px; }
|
||||
.login-card button { width: 100%; margin-top: 12px; padding: 10px 12px; border-radius:8px; border:1px solid var(--border); background:#1677ff; color:#fff; cursor:pointer; }
|
||||
.login-tip { color:#666; font-size:12px; margin-top:8px; }
|
||||
.login-err { color:#d03050; font-size:13px; min-height: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<h1>请输入访问密码</h1>
|
||||
<div class="field">
|
||||
<input id="loginPwd" type="password" placeholder="输入密码" />
|
||||
</div>
|
||||
<div id="loginErr" class="login-err"></div>
|
||||
<button id="loginBtn">登录</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const btn = document.getElementById('loginBtn');
|
||||
const pwdInput = document.getElementById('loginPwd');
|
||||
const err = document.getElementById('loginErr');
|
||||
async function doLogin() {
|
||||
const password = pwdInput.value.trim();
|
||||
err.textContent = '';
|
||||
try {
|
||||
const resp = await fetch('api.php?action=login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
// 设置本地标记,供静态页前端校验
|
||||
localStorage.setItem('auth_ok', '1');
|
||||
location.href = 'index.php';
|
||||
} else {
|
||||
err.textContent = data.message || '登录失败';
|
||||
}
|
||||
} catch (e) {
|
||||
err.textContent = '网络错误,请稍后再试';
|
||||
}
|
||||
}
|
||||
btn.addEventListener('click', doLogin);
|
||||
pwdInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
// 简单的单页应用,左侧菜单 + 右侧页面(工作进度看板)
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>项目工作进度与笔记管理</title>
|
||||
<link rel="stylesheet" href="assets/css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<main class="main">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<strong>项目列表 · 工作进度看板</strong>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<input type="month" id="monthFilterInput" class="input" title="按年月筛选" />
|
||||
<button class="btn" id="monthFilterClearBtn" title="清除月份">清除月份</button>
|
||||
<select id="statusFilterSelect" class="select" title="筛选状态">
|
||||
<option value="全部">全部</option>
|
||||
<option value="待做">待做</option>
|
||||
<option value="进行">进行</option>
|
||||
<option value="完成">完成</option>
|
||||
<option value="异常">异常</option>
|
||||
</select>
|
||||
<input id="projectSearchInput" class="input" placeholder="搜索项目" />
|
||||
<input id="newProjectName" class="input" placeholder="新项目名称" />
|
||||
<button class="btn primary" onclick="createProject()">新增项目</button>
|
||||
<button class="btn" onclick="cleanupUnusedUploads()" title="后台检测并删除未使用的图片/附件">清理未用图片</button>
|
||||
<button class="btn danger" onclick="logout()" title="退出登录">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="status-board" id="statusBoard">
|
||||
<!-- JS 渲染四个列:待做、进行、完成、异常 -->
|
||||
</section>
|
||||
|
||||
<section class="chart-card">
|
||||
<h3 class="chart-title">月份曲线图 <span class="muted-small">展示最近12个月各状态项目数量</span></h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="monthlyChart"></canvas>
|
||||
</div>
|
||||
<div id="chartMsg" class="chart-msg"></div>
|
||||
</section>
|
||||
|
||||
<section class="chart-card">
|
||||
<h3 class="chart-title">导出的HTML管理
|
||||
<span style="display:flex; gap:8px; align-items:center;">
|
||||
<input id="exportsSearchInput" class="input" placeholder="搜索文件名" />
|
||||
<input type="month" id="exportsMonthInput" class="input" title="按月份筛选" />
|
||||
<button class="btn" id="exportsMonthClearBtn" title="清除月份筛选">清除月份</button>
|
||||
<button class="btn" onclick="loadExports()" title="刷新导出列表">刷新</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div class="muted-small" id="exportsCount"></div>
|
||||
<div id="exportsList"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 详情改为单独页面,不再使用悬浮弹窗 -->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
79
project.php
Normal file
79
project.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
session_start();
|
||||
if (!isset($_SESSION['auth']) || !$_SESSION['auth']) {
|
||||
// 未登录时跳回首页登录
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
$pid = $_GET['id'] ?? '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>项目详情</title>
|
||||
<link rel="stylesheet" href="assets/css/style.css" />
|
||||
<style>
|
||||
.page-wrap { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.topbar { display:flex; align-items:center; justify-content:space-between; margin-bottom: 12px; }
|
||||
.topbar .left { display:flex; align-items:center; gap:8px; }
|
||||
.topbar h1 { margin: 0; font-size: 18px; }
|
||||
.notes-add-bar { display:flex; gap:8px; align-items:center; margin-top:8px; }
|
||||
.note { border:1px dashed var(--border); padding:8px; border-radius:8px; margin:8px 0; }
|
||||
.note-preview img { max-width: 100%; }
|
||||
.hidden { display:none; }
|
||||
</style>
|
||||
<script>
|
||||
window.PAGE_PROJECT_ID = '<?php echo htmlspecialchars($pid, ENT_QUOTES); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-wrap">
|
||||
<div class="topbar">
|
||||
<div class="left">
|
||||
<a class="btn back-btn" href="index.php">返回列表</a>
|
||||
<strong id="pageProjectTitle">项目详情</strong>
|
||||
<span id="pageProjectStatus" class="ml-8 muted"></span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<button class="btn danger" id="logoutBtn" title="退出登录">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-actions">
|
||||
<input id="renameProjectInput" class="input" placeholder="重命名项目" />
|
||||
<select id="statusSelect" class="select">
|
||||
<option value="待做">待做</option>
|
||||
<option value="进行">进行</option>
|
||||
<option value="完成">完成</option>
|
||||
<option value="异常">异常</option>
|
||||
</select>
|
||||
<input id="etaInput" type="date" class="input" placeholder="计划完成日期" style="display:none" />
|
||||
<button class="btn" id="saveMetaBtn">保存项目信息</button>
|
||||
<button class="btn danger" id="deleteProjectBtn">删除项目</button>
|
||||
<button class="btn" id="exportHtmlBtn">导出为HTML</button>
|
||||
<button class="btn" id="cleanupBtn" title="后端扫描未引用文件并删除">清理未用图片</button>
|
||||
</div>
|
||||
|
||||
<div id="projectRemind" class="remind-banner" style="margin-bottom:10px;"></div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h4 class="notes-title">笔记</h4>
|
||||
<div id="notesList"></div>
|
||||
<div class="notes-add-bar">
|
||||
<textarea id="newNoteInput" class="input" placeholder="新增笔记内容"></textarea>
|
||||
<button class="btn" id="newNoteAddImageBtn" title="为新笔记插入图片">添加图片</button>
|
||||
<input type="file" id="newNoteImageInput" accept="image/*" class="hidden" />
|
||||
<button class="btn primary" id="addNoteBtn">添加笔记</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="muted" id="pageMsg" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/project.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user