新增管理器
This commit is contained in:
BIN
__pycache__/main.cpython-313.pyc
Normal file
BIN
__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
129
main.py
129
main.py
@@ -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():
|
||||
|
||||
@@ -7,3 +7,9 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"flask>=3.1",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
# Image processing for media manager
|
||||
dependencies = [
|
||||
"Pillow>=10.0"
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
utils/__pycache__/media_manager.cpython-313.pyc
Normal file
BIN
utils/__pycache__/media_manager.cpython-313.pyc
Normal file
Binary file not shown.
105
utils/media_manager.py
Normal file
105
utils/media_manager.py
Normal 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
|
||||
Reference in New Issue
Block a user