新增管理器

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 pathlib import Path
from typing import Any
import os
import uuid
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__)
@@ -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]
@@ -195,6 +237,40 @@ def api_list_projects() -> Response:
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]
def api_create_project() -> tuple[Response, int]:
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))
@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]
def api_create_page(slug: str) -> tuple[Response, int]:
if not _project_dir(slug).exists():

View File

@@ -7,3 +7,9 @@ requires-python = ">=3.13"
dependencies = [
"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>
<html lang="zh-TW">
<head>

View File

@@ -108,6 +108,9 @@
<i class="la la-external-link-alt fs-6"></i>
</a>
<!-- 快速設定按鈕(點擊開啟儀表板的設定) -->
<button id="quick-settings-btn" class="btn btn-light px-1" title="快速設定">⚙️</button>
<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"
@@ -2308,6 +2311,21 @@ window.VVVEB_PROJECT_SLUG = "{% endraw %}{{ slug | safe }}{% 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>
</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