新增管理器

This commit is contained in:
2026-05-26 11:53:28 +08:00
parent e9ac2ae1a8
commit 03d7eca139
7 changed files with 391 additions and 0 deletions

Binary file not shown.

129
main.py
View File

@@ -8,8 +8,13 @@ import shutil
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import os
import uuid
from flask import Flask, Response, abort, jsonify, render_template, request, send_from_directory from flask import Flask, Response, abort, jsonify, render_template, request, send_from_directory
from werkzeug.utils import secure_filename
from utils.media_manager import save_upload, list_media, delete_media
app = Flask(__name__) app = Flask(__name__)
@@ -132,6 +137,43 @@ def _copy_blank_template(dest: Path) -> None:
) )
def build_page_tree(slug: str, max_depth: int = 3) -> dict[str, Any]:
"""遞歸建構專案的頁面樹狀結構(根據資料夾結構)。"""
proj_dir = _project_dir(slug)
def scan_folder(folder: Path, current_depth: int = 0):
items: list[dict[str, Any]] = []
if current_depth > max_depth:
return items
try:
names = sorted([p.name for p in folder.iterdir()])
except Exception:
return items
for name in names:
if name.startswith("."):
continue
full = folder / name
if full.is_dir():
children = scan_folder(full, current_depth + 1)
items.append({
"type": "folder",
"name": name,
"title": name.replace("-", " ").title(),
"children": children,
})
elif full.is_file() and full.suffix.lower() == ".html":
rel = str(full.relative_to(proj_dir)).replace("\\", "/")
items.append({
"type": "file",
"name": rel,
"title": Path(name).stem.replace("-", " ").title(),
})
return items
return {"root": scan_folder(proj_dir, 0)}
# ── 路由:一般頁面 ────────────────────────────────────────────────────────── # ── 路由:一般頁面 ──────────────────────────────────────────────────────────
@app.route("/") # type: ignore[untyped-decorator] @app.route("/") # type: ignore[untyped-decorator]
@@ -195,6 +237,40 @@ def api_list_projects() -> Response:
return jsonify(projects) return jsonify(projects)
@app.route("/api/projects/<slug>/settings", methods=["GET"]) # type: ignore[untyped-decorator]
def api_get_settings(slug: str) -> tuple[Response, int] | Response:
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
data = _load_project(slug)
settings = data.get("settings", {})
# provide sensible defaults
defaults = {
"title": data.get("name", slug),
"description": data.get("description", ""),
"logo_url": None,
"favicon_url": None,
"primary_color": "#007bff",
"secondary_color": "#6c757d",
}
merged = {**defaults, **settings}
return jsonify(merged)
@app.route("/api/projects/<slug>/settings", methods=["PUT"]) # type: ignore[untyped-decorator]
def api_put_settings(slug: str) -> tuple[Response, int] | Response:
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
body: dict[str, Any] = request.get_json(force=True) or {}
data = _load_project(slug)
settings = data.get("settings", {})
settings.update(body)
data["settings"] = settings
data["updated_at"] = _now_iso()
_save_project(slug, data)
return jsonify({"ok": True, "settings": settings})
@app.route("/api/projects", methods=["POST"]) # type: ignore[untyped-decorator] @app.route("/api/projects", methods=["POST"]) # type: ignore[untyped-decorator]
def api_create_project() -> tuple[Response, int]: def api_create_project() -> tuple[Response, int]:
body: dict[str, Any] = request.get_json(force=True) or {} body: dict[str, Any] = request.get_json(force=True) or {}
@@ -261,6 +337,59 @@ def api_list_pages(slug: str) -> tuple[Response, int] | Response:
return jsonify(_list_pages(slug)) return jsonify(_list_pages(slug))
@app.route("/api/projects/<slug>/pages-tree", methods=["GET"]) # type: ignore[untyped-decorator]
def api_pages_tree(slug: str) -> tuple[Response, int] | Response:
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
tree = build_page_tree(slug, max_depth=5)
return jsonify(tree)
@app.route("/api/projects/<slug>/media/upload", methods=["POST"]) # type: ignore[untyped-decorator]
def api_media_upload(slug: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
if "file" not in request.files:
return jsonify({"error": "缺少檔案 (file)"}), 400
f = request.files["file"]
if f.filename == "":
return jsonify({"error": "檔名為空"}), 400
# basic validation
filename = secure_filename(f.filename)
meta = save_upload(proj_dir, f)
# substitute slug in returned urls
if isinstance(meta.get("url"), str):
meta["url"] = meta["url"].replace("{slug}", slug)
if isinstance(meta.get("thumb"), str):
meta["thumb"] = meta["thumb"].replace("{slug}", slug)
return jsonify({"ok": True, "file": meta})
@app.route("/api/projects/<slug>/media/list", methods=["GET"]) # type: ignore[untyped-decorator]
def api_media_list(slug: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
items = list_media(proj_dir)
# patch urls
for it in items:
if "filename" in it and "date" in it:
it["url"] = f"/sites/{slug}/media/images/{it['date']}/{it['filename']}"
return jsonify(items)
@app.route("/api/projects/<slug>/media/<path:rel_path>", methods=["DELETE"]) # type: ignore[untyped-decorator]
def api_media_delete(slug: str, rel_path: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
ok = delete_media(proj_dir, rel_path)
if not ok:
return jsonify({"error": "刪除失敗或檔案不存在"}), 400
return jsonify({"ok": True})
@app.route("/api/projects/<slug>/pages", methods=["POST"]) # type: ignore[untyped-decorator] @app.route("/api/projects/<slug>/pages", methods=["POST"]) # type: ignore[untyped-decorator]
def api_create_page(slug: str) -> tuple[Response, int]: def api_create_page(slug: str) -> tuple[Response, int]:
if not _project_dir(slug).exists(): if not _project_dir(slug).exists():

View File

@@ -6,4 +6,10 @@ readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"flask>=3.1", "flask>=3.1",
]
[tool.pip]
# Image processing for media manager
dependencies = [
"Pillow>=10.0"
] ]

View File

@@ -1,3 +1,136 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>管理器 - Dashboard</title>
<link rel="stylesheet" href="/static/css/my-style.css">
<style>
.tabs { display:flex; gap:8px; margin-bottom:12px; }
.tab { padding:8px 12px; border:1px solid #ddd; cursor:pointer; }
.active { background:#f0f0f0; }
</style>
</head>
<body>
<header>
<h1>網站管理器</h1>
</header>
<div class="tabs">
<div class="tab active" data-tab="overview">概覽</div>
<div class="tab" data-tab="settings">網站設定</div>
<div class="tab" data-tab="pages">頁面管理</div>
<div class="tab" data-tab="media">媒體庫</div>
</div>
<div id="content">
<div id="overview" class="panel">選擇一個專案在右側編輯。</div>
<div id="settings" class="panel" style="display:none">
<h2>網站設定</h2>
<form id="settingsForm">
<label>網站標題<br><input id="siteTitle" type="text"></label><br>
<label>描述<br><textarea id="siteDesc"></textarea></label><br>
<button id="saveSettings">儲存設定</button>
</form>
</div>
<div id="pages" class="panel" style="display:none">
<h2>頁面管理</h2>
<div id="pagesTree"></div>
</div>
<div id="media" class="panel" style="display:none">
<h2>媒體庫</h2>
<input type="file" id="mediaFile"><button id="uploadBtn">上傳</button>
<div id="mediaGrid"></div>
</div>
</div>
<script>
document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',e=>{
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
document.querySelectorAll('.panel').forEach(p=>p.style.display='none');
document.getElementById(t.dataset.tab).style.display='block';
}));
// Dashboard logic: load settings and media for ?slug=...
(function(){
const params = new URLSearchParams(location.search);
const slug = params.get('slug');
if(!slug){
document.getElementById('overview').innerText = '請在網址列加入 ?slug=your-site 以管理專案。';
return;
}
// load settings
async function loadSettings(){
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/settings`);
if(!res.ok) return;
const data = await res.json();
document.getElementById('siteTitle').value = data.title || '';
document.getElementById('siteDesc').value = data.description || '';
}
document.getElementById('saveSettings').addEventListener('click', async function(e){
e.preventDefault();
const body = {
title: document.getElementById('siteTitle').value,
description: document.getElementById('siteDesc').value
};
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/settings`, {
method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
if(res.ok) alert('設定已儲存'); else alert('儲存失敗');
});
// media upload
async function listMedia(){
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/list`);
const grid = document.getElementById('mediaGrid');
grid.innerHTML = '';
if(!res.ok) return;
const items = await res.json();
items.reverse();
items.forEach(it => {
const card = document.createElement('div');
card.className = 'media-card';
card.style.border = '1px solid #ddd'; card.style.padding='8px'; card.style.display='inline-block'; card.style.margin='6px';
const img = document.createElement('img');
img.src = it.thumb || it.url;
img.style.width = '160px'; img.style.height='auto';
const info = document.createElement('div');
info.innerHTML = `<div>${it.original_name || it.filename}</div><div style="font-size:12px;color:#666">${it.uploaded_at||''}</div>`;
const btnCopy = document.createElement('button'); btnCopy.textContent='複製連結';
btnCopy.addEventListener('click', ()=>{ navigator.clipboard.writeText(it.url); alert('已複製連結'); });
const btnDel = document.createElement('button'); btnDel.textContent='刪除'; btnDel.style.marginLeft='6px';
btnDel.addEventListener('click', async ()=>{
if(!confirm('確定要刪除此媒體檔?')) return;
// rel_path expected like media/images/YYYY-MM/filename
const rel = `media/images/${it.date}/${it.filename}`;
const del = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/${encodeURIComponent(rel)}`, {method:'DELETE'});
if(del.ok){ alert('刪除成功'); listMedia(); } else alert('刪除失敗');
});
card.appendChild(img); card.appendChild(info); card.appendChild(btnCopy); card.appendChild(btnDel);
grid.appendChild(card);
});
}
document.getElementById('uploadBtn').addEventListener('click', async ()=>{
const fileInput = document.getElementById('mediaFile');
if(!fileInput.files.length){ alert('請選擇檔案'); return; }
const fd = new FormData(); fd.append('file', fileInput.files[0]);
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/upload`, {method:'POST', body: fd});
if(res.ok){ alert('上傳完成'); fileInput.value=''; listMedia(); } else { alert('上傳失敗'); }
});
// init
loadSettings(); listMedia();
})();
</script>
</body>
</html>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-TW"> <html lang="zh-TW">
<head> <head>

View File

@@ -108,6 +108,9 @@
<i class="la la-external-link-alt fs-6"></i> <i class="la la-external-link-alt fs-6"></i>
</a> </a>
<!-- 快速設定按鈕(點擊開啟儀表板的設定) -->
<button id="quick-settings-btn" class="btn btn-light px-1" title="快速設定">⚙️</button>
<div class="btn-group responsive-btns" role="group"> <div class="btn-group responsive-btns" role="group">
<button type="button" class="btn btn-light btn-sm px-1 me-1" data-bs-toggle="dropdown" <button type="button" class="btn btn-light btn-sm px-1 me-1" data-bs-toggle="dropdown"
@@ -2308,6 +2311,21 @@ window.VVVEB_PROJECT_SLUG = "{% endraw %}{{ slug | safe }}{% raw %}";
</script> </script>
<script src="/static/js/my-editor.js?v={% endraw %}{{ range(1, 100000) | random }}{% raw %}"></script> <script src="/static/js/my-editor.js?v={% endraw %}{{ range(1, 100000) | random }}{% raw %}"></script>
<script>
// Quick settings button opens dashboard for current project
try{
document.addEventListener('DOMContentLoaded', function(){
var btn = document.getElementById('quick-settings-btn');
if(!btn) return;
btn.addEventListener('click', function(e){
var slug = window.VVVEB_PROJECT_SLUG || '';
var url = '/dashboard' + (slug ? ('?slug='+encodeURIComponent(slug)) : '');
window.open(url, '_blank');
});
});
}catch(e){console.warn(e)}
</script>
</body> </body>
</html> </html>

Binary file not shown.

105
utils/media_manager.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any
import uuid
def _now_iso() -> str:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
def save_upload(project_dir: Path, file_storage) -> dict[str, Any]:
"""Save uploaded file under project media directory and generate thumbnail.
Returns metadata dict.
"""
uploads_dir = project_dir / "media" / "images"
date_folder = datetime.utcnow().strftime("%Y-%m")
target_dir = uploads_dir / date_folder
target_dir.mkdir(parents=True, exist_ok=True)
original_name = file_storage.filename or "upload"
ext = Path(original_name).suffix.lower() or ".bin"
safe_name = f"{uuid.uuid4().hex}{ext}"
target_path = target_dir / safe_name
# save original
file_storage.save(str(target_path))
# try to generate thumbnail for common image types
thumb_name = None
try:
from PIL import Image
if ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]:
im = Image.open(str(target_path))
im.thumbnail((400, 400))
thumb_name = f"{uuid.uuid4().hex}_thumb{ext}"
thumb_path = target_dir / thumb_name
im.save(str(thumb_path))
except Exception:
thumb_name = None
meta = {
"filename": safe_name,
"original_name": original_name,
"url": f"/sites/{{slug}}/media/images/{date_folder}/{safe_name}",
"thumb": (f"/sites/{{slug}}/media/images/{date_folder}/{thumb_name}" if thumb_name else None),
"size": target_path.stat().st_size,
"uploaded_at": _now_iso(),
}
# update uploads index
uploads_index = uploads_dir / "uploads.json"
data = {}
if uploads_index.exists():
try:
data = json.loads(uploads_index.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault(date_folder, []).append({
"filename": safe_name,
"original_name": original_name,
"size": meta["size"],
"uploaded_at": meta["uploaded_at"],
})
uploads_index.parent.mkdir(parents=True, exist_ok=True)
uploads_index.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return meta
def list_media(project_dir: Path) -> list[dict[str, Any]]:
uploads_dir = project_dir / "media" / "images"
uploads_index = uploads_dir / "uploads.json"
if not uploads_index.exists():
return []
try:
data = json.loads(uploads_index.read_text(encoding="utf-8"))
except Exception:
return []
items = []
for date_folder, files in data.items():
for f in files:
items.append({
"date": date_folder,
**f,
})
return items
def delete_media(project_dir: Path, rel_path: str) -> bool:
target = (project_dir / rel_path).resolve()
if not str(target).startswith(str(project_dir.resolve())):
return False
if not target.exists():
return False
try:
target.unlink()
except Exception:
return False
return True