Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ https://www.fhl.mom/register?aff=86L574B4T2N9 (包含codex和GPT image 2模

功能请求/功能更新/视频教程/联系我,都可以在B站评论或私信:https://space.bilibili.com/78652351

## 画布导入

画布列表页支持导入 `.json` 和 `.zip` 格式的画布文件。点击顶部工具栏的导入按钮,或将文件拖到画布列表区域,即可导入到当前项目。

导入 zip 时会读取 `canvas.json` 和 `resources-manifest.json`,自动恢复资源引用;已存在的同名同大小资源会按哈希复用,避免重复写入。导入后的画布会生成新的 ID,并放置到当前视图附近的空白位置,避免覆盖原有画布。

----

【新增了version文件,我每次更新都会更新version的版本号,如果你下载version文件,打开项目后,导航栏的GitHub按键就会提示新版本,如果不想查看更新提示,就删除version文件】
Expand Down
171 changes: 171 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3196,6 +3196,142 @@ def new_canvas(title="未命名画布", icon="layers", kind="classic", project=N
save_canvas(canvas)
return canvas


def imported_canvas_payload(source, project=None, board_x=None, board_y=None, resource_mapping=None):
if not isinstance(source, dict):
raise HTTPException(status_code=400, detail="画布文件格式不正确")
if not isinstance(source.get("nodes"), list):
raise HTTPException(status_code=400, detail="画布 JSON 缺少 nodes")
connections = source.get("connections")
if not isinstance(connections, list):
connections = []
timestamp = now_ms()
canvas = json.loads(json.dumps(source, ensure_ascii=False))
if resource_mapping:
canvas = canvas_workflow_replace_strings(canvas, resource_mapping)
original_title = str(canvas.get("title") or "导入画布").strip()[:72] or "导入画布"
old_id = str(canvas.get("id") or "")
canvas["id"] = uuid.uuid4().hex
canvas["title"] = f"{original_title}(导入)"[:80]
canvas["kind"] = normalize_canvas_kind(canvas.get("kind"))
canvas["project"] = str(project or "").strip() or DEFAULT_PROJECT_ID
canvas["created_at"] = timestamp
canvas["updated_at"] = timestamp
canvas["deleted_at"] = 0
canvas["pinned"] = False
canvas["nodes"] = canvas.get("nodes") if isinstance(canvas.get("nodes"), list) else []
canvas["connections"] = connections
canvas["viewport"] = canvas.get("viewport") if isinstance(canvas.get("viewport"), dict) else {"x": 0, "y": 0, "scale": 1}
canvas["settings"] = canvas.get("settings") if isinstance(canvas.get("settings"), dict) else {}
canvas["logs"] = canvas.get("logs") if isinstance(canvas.get("logs"), list) else []
if board_x is not None:
canvas["board_x"] = float(board_x)
else:
canvas.pop("board_x", None)
if board_y is not None:
canvas["board_y"] = float(board_y)
else:
canvas.pop("board_y", None)
canvas["imported_from"] = old_id
save_canvas(canvas)
return canvas


def read_canvas_import_archive(raw: bytes, filename: str):
resource_mapping = {}
imported_resources = []
lower = str(filename or "").lower()
if lower.endswith(".zip") or raw[:2] == b"PK":
try:
with zipfile.ZipFile(BytesIO(raw), "r") as zf:
names = zf.namelist()
canvas_name = "canvas.json" if "canvas.json" in names else next((n for n in names if n.lower().endswith("/canvas.json")), "")
if not canvas_name:
raise HTTPException(status_code=400, detail="压缩包中没有 canvas.json")
canvas = json.loads(zf.read(canvas_name).decode("utf-8-sig"))
manifest = {}
manifest_name = "resources-manifest.json" if "resources-manifest.json" in names else next((n for n in names if n.lower().endswith("/resources-manifest.json")), "")
if manifest_name:
try:
manifest = json.loads(zf.read(manifest_name).decode("utf-8-sig"))
except Exception:
manifest = {}
resources = manifest.get("resources") if isinstance(manifest, dict) else []
if not isinstance(resources, list):
resources = []
stamp = time.strftime("%Y%m%d-%H%M%S")
import_dir = os.path.join(OUTPUT_INPUT_DIR, f"canvas_import_{stamp}_{uuid.uuid4().hex[:6]}")
for index, res in enumerate(resources):
if not isinstance(res, dict) or res.get("skipped"):
continue
archive = str(res.get("file") or res.get("archive") or "").replace("\\", "/").lstrip("/")
if not archive or archive not in names:
continue
base = sanitize_export_filename(res.get("name") or os.path.basename(archive), os.path.basename(archive) or f"resource-{index + 1}.bin")
info = zf.getinfo(archive)
with zf.open(archive) as src:
content = src.read()
digest = hashlib.sha256(content).hexdigest()
new_url = find_duplicate_import_asset(base, info.file_size, digest)
reused = bool(new_url)
if not new_url:
os.makedirs(import_dir, exist_ok=True)
target = os.path.join(import_dir, base)
if os.path.exists(target):
stem, ext = os.path.splitext(base)
target = os.path.join(import_dir, f"{stem}_{uuid.uuid4().hex[:6]}{ext}")
with open(target, "wb") as dst:
dst.write(content)
rel = os.path.relpath(target, ASSETS_DIR).replace("\\", "/")
new_url = f"/assets/{rel}"
old_url = str(res.get("url") or "").strip()
if old_url:
resource_mapping[old_url] = new_url
resource_mapping[archive] = new_url
resource_mapping[f"./{archive}"] = new_url
resource_mapping[os.path.basename(archive)] = new_url
imported_resources.append({"from": old_url or archive, "to": new_url, "reused": reused})
return canvas, resource_mapping, imported_resources
except HTTPException:
raise
except zipfile.BadZipFile as exc:
raise HTTPException(status_code=400, detail="无法读取画布压缩包") from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=f"无法解析画布压缩包:{exc}") from exc
try:
return json.loads(raw.decode("utf-8-sig")), {}, []
except Exception as exc:
raise HTTPException(status_code=400, detail=f"无法解析画布 JSON:{exc}") from exc


def find_duplicate_import_asset(name: str, size: int, digest: str = ""):
safe_name = os.path.basename(str(name or "")).strip()
if not safe_name or size < 0:
return ""
root = os.path.abspath(ASSETS_DIR)
if not os.path.isdir(root):
return ""
for dirpath, _, filenames in os.walk(root):
for filename in filenames:
if filename != safe_name:
continue
path = os.path.join(dirpath, filename)
try:
if os.path.getsize(path) != size:
continue
if digest:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
if h.hexdigest() != digest:
continue
rel = os.path.relpath(path, ASSETS_DIR).replace("\\", "/")
return f"/assets/{rel}"
except Exception:
continue
return ""

def load_canvas(canvas_id):
path = canvas_path(canvas_id)
if not os.path.exists(path):
Expand Down Expand Up @@ -13826,6 +13962,41 @@ async def trashed_canvases():
async def create_canvas(payload: CanvasCreateRequest):
return {"canvas": new_canvas(payload.title, payload.icon, payload.kind, payload.project, payload.board_x, payload.board_y)}


@app.post("/api/canvases/import")
async def import_canvas(
file: UploadFile = File(...),
project: str = Form(""),
board_x: str = Form(""),
board_y: str = Form(""),
):
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="文件为空")
def maybe_float(value):
text = str(value or "").strip()
if not text:
return None
try:
return float(text)
except Exception:
return None
source, resource_mapping, imported_resources = read_canvas_import_archive(raw, file.filename or "")
canvas = imported_canvas_payload(
source,
project=project,
board_x=maybe_float(board_x),
board_y=maybe_float(board_y),
resource_mapping=resource_mapping,
)
return {
"canvas": canvas_record(canvas),
"id": canvas.get("id"),
"resource_count": sum(1 for item in imported_resources if not item.get("reused")),
"reused_resource_count": sum(1 for item in imported_resources if item.get("reused")),
"resource_map": resource_mapping,
}

@app.get("/api/canvases/{canvas_id}/meta")
async def get_canvas_meta(canvas_id: str):
canvas = load_canvas(canvas_id)
Expand Down
19 changes: 17 additions & 2 deletions static/canvas-list.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<script src="/static/js/theme.js?v=2026.07.3.1783117645"></script>
<script src="/static/js/touch-mouse.js?v=2026.07.3.1783079819"></script>
<script src="/static/js/i18n.js?v=2026.07.3.1783116068"></script>
<link rel="stylesheet" href="/static/css/canvas-list.css?v=2026.07.3.1783079294">
<link rel="stylesheet" href="/static/css/canvas-list.css?v=2026.07.5.canvas-import">
<link rel="stylesheet" href="/static/css/theme.css?v=2026.07.3.1782696682">
</head>
<body>
Expand Down Expand Up @@ -57,7 +57,9 @@
<span id="boardCanvasCount" class="ws-board-count">0</span>
</div>
<div class="ws-topbar-right">
<input id="importCanvasInput" type="file" accept=".json,.zip,application/json,application/zip" hidden>
<button id="pasteCanvasBtn" class="ws-paste-btn" type="button" title="粘贴到此项目" style="display:none"><i data-lucide="clipboard-paste" class="w-4 h-4"></i><span>粘贴到此项目</span></button>
<button id="importCanvasBtn" class="ws-icon-btn" type="button" title="导入画布" aria-label="导入画布"><i data-lucide="upload" class="w-4 h-4"></i></button>
<button id="boardResetView" class="ws-icon-btn" type="button" title="重置视图" aria-label="重置视图"><i data-lucide="locate-fixed" class="w-4 h-4"></i></button>
<button id="boardRefresh" class="ws-icon-btn" type="button" title="刷新" aria-label="刷新"><i data-lucide="refresh-cw" class="w-4 h-4"></i></button>
<button id="newCanvasBtn" class="ws-primary-btn" type="button"><i data-lucide="plus" class="w-4 h-4"></i><span>新建画布</span></button>
Expand All @@ -67,6 +69,19 @@
<!-- Board (pan/zoom) -->
<div id="board" class="ws-board">
<div id="boardWorld" class="ws-board-world"></div>
<div id="importProgress" class="ws-import-progress" aria-live="polite">
<div class="ws-import-progress-panel">
<div class="ws-import-progress-head">
<span class="ws-import-progress-icon"><i data-lucide="upload-cloud" class="w-4 h-4"></i></span>
<div>
<div id="importProgressTitle" class="ws-import-progress-title">导入画布</div>
<div id="importProgressSub" class="ws-import-progress-sub">准备上传...</div>
</div>
<span id="importProgressPct" class="ws-import-progress-pct">0%</span>
</div>
<div class="ws-import-progress-track"><div id="importProgressBar" class="ws-import-progress-bar"></div></div>
</div>
</div>
<div id="boardEmptyHint" class="ws-board-empty">
<div class="ws-board-empty-icon"><i data-lucide="mouse-pointer-click" class="w-7 h-7"></i></div>
<div class="ws-board-empty-text">暂无画布</div>
Expand All @@ -90,6 +105,6 @@
<div id="boardStatus" class="ws-status"></div>
</main>
</div>
<script src="/static/js/canvas-list.js?v=2026.07.3.1783077284"></script>
<script src="/static/js/canvas-list.js?v=2026.07.5.canvas-import"></script>
</body>
</html>
25 changes: 24 additions & 1 deletion static/css/canvas-list.css
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,10 @@ html[data-studio-scale="off"].studio-scale-managed .workspace {
.ws-board-name { font-size:17px; font-weight:850; letter-spacing:0; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:40vw; }
.ws-board-count { min-width:20px; height:20px; padding:0 8px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; background:var(--soft-2); color:var(--muted); font-size:11px; font-weight:800; }
.ws-topbar-right { display:flex; align-items:center; gap:8px; flex:0 0 auto; }
.ws-icon-btn { width:36px; height:36px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--muted); background:var(--card-solid); border:1px solid var(--line); cursor:pointer; transition:all .15s var(--ease); }
.ws-icon-btn { width:36px; min-width:36px; height:36px; flex:0 0 36px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--muted); background:var(--card-solid); border:1px solid var(--line); cursor:pointer; transition:all .15s var(--ease); }
.ws-icon-btn:hover { color:var(--text); border-color:var(--line-2); background:var(--soft); box-shadow:0 6px 14px var(--shadow); }
.ws-icon-btn:disabled { opacity:.58; cursor:default; transform:none; box-shadow:none; }
#importCanvasBtn { display:flex !important; visibility:visible !important; }
.ws-primary-btn { height:36px; padding:0 14px; border-radius:8px; display:inline-flex; align-items:center; justify-content:center; gap:7px; background:var(--strong); color:var(--strong-text); border:none; cursor:pointer; font-size:12px; font-weight:850; box-shadow:0 8px 18px var(--shadow); }
.ws-primary-btn:hover { transform:translateY(-1px); box-shadow:0 12px 24px var(--shadow-strong); }
/* 粘贴到此项目:剪切后才出现的强调按钮,固定高对比色避免主题变量冲淡文字。 */
Expand All @@ -140,8 +142,25 @@ html[data-studio-scale="off"].studio-scale-managed .workspace {

.ws-board { position:relative; flex:1; min-height:0; overflow:hidden; background:var(--page); background-image:linear-gradient(rgba(100,116,139,.045) 1px, transparent 1px), linear-gradient(90deg, rgba(100,116,139,.045) 1px, transparent 1px), radial-gradient(var(--grid) 1px, transparent 1px); background-size:120px 120px, 120px 120px, 24px 24px; cursor:grab; user-select:none; }
.ws-board.panning { cursor:grabbing; }
.ws-board.import-dragging::after { content:""; position:absolute; inset:18px; z-index:34; border:2px dashed var(--accent); border-radius:8px; background:rgba(37,99,235,.08); box-shadow:inset 0 0 0 1px rgba(255,255,255,.18); pointer-events:none; }
.ws-board-world { position:absolute; left:0; top:0; transform-origin:0 0; will-change:transform; }


.ws-import-progress { position:absolute; inset:0; z-index:35; display:flex; align-items:center; justify-content:center; padding:20px; background:rgba(246,247,249,.42); opacity:0; pointer-events:none; transition:opacity .18s var(--ease); }
.ws-import-progress.open { opacity:1; pointer-events:auto; }
.ws-import-progress-panel { width:min(420px, calc(100vw - 56px)); padding:14px; border-radius:8px; background:var(--card-solid); border:1px solid var(--line); box-shadow:0 20px 50px var(--shadow-strong); }
.ws-import-progress-head { display:flex; align-items:center; gap:10px; min-width:0; }
.ws-import-progress-icon { width:34px; height:34px; flex:0 0 34px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--accent); background:var(--accent-soft); border:1px solid var(--accent-line); }
.ws-import-progress-title { font-size:13px; line-height:1.2; font-weight:850; color:var(--text); }
.ws-import-progress-sub { margin-top:3px; font-size:11.5px; line-height:1.4; font-weight:650; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:260px; }
.ws-import-progress-pct { margin-left:auto; flex:0 0 auto; min-width:42px; text-align:right; font-size:12px; font-weight:850; color:var(--muted); }
.ws-import-progress-track { margin-top:12px; height:7px; overflow:hidden; border-radius:999px; background:var(--soft-2); border:1px solid var(--line); }
.ws-import-progress-bar { width:0%; height:100%; border-radius:999px; background:var(--accent); transition:width .18s var(--ease), background .18s var(--ease); }
.ws-import-progress.done .ws-import-progress-icon { color:var(--success); background:#ecfdf5; border-color:#a7f3d0; }
.ws-import-progress.done .ws-import-progress-bar { background:var(--success); }
.ws-import-progress.failed .ws-import-progress-icon { color:var(--danger); background:var(--danger-soft); border-color:#fecaca; }
.ws-import-progress.failed .ws-import-progress-bar { background:var(--danger); }

.ws-board-empty { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); display:flex; flex-direction:column; align-items:center; gap:12px; width:min(320px, calc(100% - 48px)); color:var(--faint); }
.ws-board-empty.hidden { display:none; }
.ws-board-empty-icon { width:58px; height:58px; border-radius:8px; display:flex; align-items:center; justify-content:center; background:var(--card-solid); border:1px solid var(--line); color:var(--faint); box-shadow:0 12px 28px var(--shadow); }
Expand Down Expand Up @@ -271,6 +290,10 @@ html[data-studio-scale="off"].studio-scale-managed .workspace {
.theme-dark .ws-pop-item.danger:hover { background:rgba(220,38,38,.18); }
.theme-dark .ws-card-delete-no { background:var(--soft); }

.theme-dark .ws-import-progress { background:rgba(16,20,29,.46); }
.theme-dark .ws-import-progress.done .ws-import-progress-icon { background:rgba(5,150,105,.16); border-color:rgba(16,185,129,.34); }
.theme-dark .ws-import-progress.failed .ws-import-progress-icon { background:rgba(220,38,38,.16); border-color:rgba(248,113,113,.34); }

/* ===== Responsive ===== */
@media (max-width:760px) {
.workspace { flex-direction:column; }
Expand Down
Loading