项目改造
This commit is contained in:
@@ -153,14 +153,34 @@ def count_stratagems() -> int:
|
||||
return int(conn.execute("SELECT COUNT(*) AS n FROM stratagems").fetchone()["n"])
|
||||
|
||||
|
||||
def list_stratagems_page(page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
|
||||
def list_stratagems_page(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
keyword: str = "",
|
||||
type: str = "",
|
||||
backpack: str = "",
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
init_db()
|
||||
total = count_stratagems()
|
||||
offset = (page - 1) * page_size
|
||||
where: list[str] = []
|
||||
params: list[Any] = []
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
where.append("(name LIKE ? OR name_en LIKE ? OR alias LIKE ?)")
|
||||
params.extend([like, like, like])
|
||||
if type:
|
||||
where.append("type = ?")
|
||||
params.append(type)
|
||||
if backpack in ("0", "1"):
|
||||
where.append("backpack = ?")
|
||||
params.append(int(backpack))
|
||||
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
|
||||
with _connect() as conn:
|
||||
total = int(conn.execute(f"SELECT COUNT(*) FROM stratagems{where_sql}", params).fetchone()[0])
|
||||
offset = (page - 1) * page_size
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM stratagems ORDER BY id LIMIT ? OFFSET ?",
|
||||
(page_size, offset),
|
||||
f"SELECT * FROM stratagems{where_sql} ORDER BY id LIMIT ? OFFSET ?",
|
||||
(*params, page_size, offset),
|
||||
).fetchall()
|
||||
return [_row_to_item(r) for r in rows], total
|
||||
|
||||
|
||||
@@ -34,10 +34,20 @@ def _equip_icon(equip: dict[str, Any]) -> str:
|
||||
raw = equip.get("path") or ""
|
||||
if not raw:
|
||||
return ""
|
||||
p = basic_path / raw.replace("\\", "/")
|
||||
if not p.exists():
|
||||
return ""
|
||||
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
|
||||
rel = raw.replace("\\", "/")
|
||||
# 兼容两种路径存法:插件根相对路径 res/img/...(新/播种数据),
|
||||
# 以及 res/img 目录相对路径 img/...(旧库迁移前遗留),统一找真实文件。
|
||||
candidates = [rel]
|
||||
if rel.startswith("res/"):
|
||||
candidates.append(rel[len("res/"):])
|
||||
else:
|
||||
candidates.append("res/" + rel)
|
||||
candidates.append("res/img/" + rel)
|
||||
for cand in candidates:
|
||||
p = basic_path / cand
|
||||
if p.exists():
|
||||
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
|
||||
return ""
|
||||
|
||||
|
||||
def _item_html(equip: dict[str, Any]) -> str:
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SVG -> PNG 转换 + 可选 HD2 风格边框,用于战备图标上传。"""
|
||||
"""SVG -> PNG 转换。
|
||||
|
||||
与 docs/HD2/run.py 一致:把上传的单张 SVG 按输出尺寸(默认 550x550)栅格化成
|
||||
透明背景 PNG,不做批量,也不叠加边框。
|
||||
|
||||
优先用 cairosvg(与 run.py 完全一致);若运行环境缺 cairo 原生库,则回退到
|
||||
Chromium canvas 渲染(透明背景、同尺寸),保证 Web 后台上传始终可用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import cairosvg # type: ignore
|
||||
_HAS_CAIROSVG = True
|
||||
except Exception: # noqa: BLE001
|
||||
cairosvg = None
|
||||
_HAS_CAIROSVG = False
|
||||
|
||||
SCHEMA_SIZE = 550 # 与随包 PNG 图标一致
|
||||
TYPE_COLORS = {
|
||||
"blue": "#6bb7ea",
|
||||
"red": "#fe6d6a",
|
||||
"green": "#7ecb3f",
|
||||
}
|
||||
|
||||
basic_path = Path(__file__).resolve().parents[1]
|
||||
IMG_DIR = basic_path / "res" / "img" / "helldivers"
|
||||
@@ -23,49 +32,13 @@ IMG_DIR = basic_path / "res" / "img" / "helldivers"
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
"""把战备名转成安全的 Windows 文件名(保留中文/空格,去掉非法字符)。"""
|
||||
s = re.sub(r'[\\/:*?"<>|]', "_", name).strip()
|
||||
s = re.sub(r'[\/:*?"<>|]', "_", name).strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
return s or "strategem"
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
||||
h = hex_color.lstrip("#")
|
||||
if len(h) == 3:
|
||||
h = "".join(c * 2 for c in h)
|
||||
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _apply_frame(src_png: bytes, size: int, frame_color: str) -> bytes:
|
||||
"""把透明的 SVG 画面叠到 HD2 风格画布上:彩色边框 + 深色内底 + 居中图形。"""
|
||||
rgb = _hex_to_rgb(frame_color)
|
||||
img = Image.open(io.BytesIO(src_png)).convert("RGBA")
|
||||
canvas = Image.new("RGBA", (size, size), rgb + (255,))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
border = max(10, size // 20)
|
||||
# 外圈用类型色,内部深色
|
||||
draw.rectangle([0, 0, size - 1, size - 1], fill=rgb + (255,))
|
||||
draw.rectangle([border, border, size - 1 - border, size - 1 - border], fill=(12, 12, 12, 255))
|
||||
# 去掉透明边距再居中
|
||||
alpha = img.getchannel("A")
|
||||
bbox = alpha.getbbox()
|
||||
glyph = img.crop(bbox) if bbox else img
|
||||
inner = size - 2 * border
|
||||
avail = int(inner * 0.82)
|
||||
glyph.thumbnail((avail, avail), Image.LANCZOS)
|
||||
ox = (size - glyph.width) // 2
|
||||
oy = (size - glyph.height) // 2
|
||||
canvas.alpha_composite(glyph, (ox, oy))
|
||||
out = io.BytesIO()
|
||||
canvas.save(out, format="PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
async def _render_svg(svg_text: str, size: int) -> bytes:
|
||||
"""用 Chromium 的 canvas 把 SVG 光栅化为带透明通道的 PNG。
|
||||
|
||||
用 toDataURL 导出而非截图,避免 omit_background 在部分环境把透明背景
|
||||
拍成白色,从而保住透明 alpha。
|
||||
"""
|
||||
async def _render_svg_canvas(svg_text: str, size: int) -> bytes:
|
||||
"""用 Chromium canvas 把 SVG 光栅化为带透明通道的 PNG(兜底方案)。"""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg_text.encode("utf-8")).decode()
|
||||
@@ -92,17 +65,28 @@ async def _render_svg(svg_text: str, size: int) -> bytes:
|
||||
await page.set_content("<!-- svg to png -->")
|
||||
data_url = await page.evaluate(script)
|
||||
await browser.close()
|
||||
if not data_url:
|
||||
raise ValueError("SVG 转换失败")
|
||||
return base64.b64decode(data_url.split(",", 1)[1])
|
||||
|
||||
|
||||
async def svg_to_png(svg_text: str, framed: bool = False, frame_color: str | None = None, size: int = SCHEMA_SIZE) -> bytes:
|
||||
"""SVG -> PNG。framed=True 时叠加 HD2 风格边框到类型色。"""
|
||||
async def svg_to_png(svg_text: str, size: int = SCHEMA_SIZE) -> bytes:
|
||||
"""SVG -> PNG(透明背景,默认 550x550)。
|
||||
|
||||
参考 docs/HD2/run.py:cairosvg.svg2png 按 output_width/output_height 栅格化到目标尺寸。
|
||||
"""
|
||||
if not svg_text.strip():
|
||||
raise ValueError("SVG 内容为空")
|
||||
png = await _render_svg(svg_text, size)
|
||||
if framed:
|
||||
png = _apply_frame(png, size, frame_color or TYPE_COLORS["blue"])
|
||||
return png
|
||||
if _HAS_CAIROSVG:
|
||||
try:
|
||||
return cairosvg.svg2png(
|
||||
bytestring=svg_text.encode("utf-8"),
|
||||
output_width=size,
|
||||
output_height=size,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"cairosvg 转换失败,改用 canvas 兜底: {type(e).__name__}: {e}")
|
||||
return await _render_svg_canvas(svg_text, size)
|
||||
|
||||
|
||||
def save_icon_png(png_bytes: bytes, name: str) -> str:
|
||||
@@ -111,4 +95,4 @@ def save_icon_png(png_bytes: bytes, name: str) -> str:
|
||||
filename = sanitize_filename(name) + ".png"
|
||||
dest = IMG_DIR / filename
|
||||
dest.write_bytes(png_bytes)
|
||||
return f"res/img/helldivers/{filename}"
|
||||
return f"res/img/helldivers/{filename}"
|
||||
|
||||
@@ -24,7 +24,7 @@ from ..services.equipment_store import (
|
||||
list_stratagems_page,
|
||||
update_stratagem,
|
||||
)
|
||||
from ..utils.icon_utils import TYPE_COLORS, save_icon_png, svg_to_png
|
||||
from ..utils.icon_utils import save_icon_png, svg_to_png
|
||||
|
||||
basic_path = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -39,10 +39,10 @@ def build_admin_app() -> FastAPI | None:
|
||||
auth = require_admin
|
||||
|
||||
@app.get("/stratagems")
|
||||
async def api_list(page: int = 1, page_size: int = 20, _: dict = auth):
|
||||
async def api_list(page: int = 1, page_size: int = 20, q: str = "", type: str = "", backpack: str = "", _: dict = auth):
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
items, total = list_stratagems_page(page, page_size)
|
||||
items, total = list_stratagems_page(page, page_size, keyword=q, type=type, backpack=backpack)
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
@app.post("/stratagems")
|
||||
@@ -86,8 +86,17 @@ def build_admin_app() -> FastAPI | None:
|
||||
|
||||
@app.get("/img/{path:path}")
|
||||
async def icon_file(path: str):
|
||||
# 兼容多种存量路径:res/img/...(插件根相对,新)、img/...(旧库,res/img 相对)、
|
||||
# 以及直接/相对 res/img 的路径,统一归一化到 res/img 下的相对路径再解析。
|
||||
rel = path.replace("\\", "/").lstrip("/")
|
||||
if rel.startswith("res/img/"):
|
||||
rel = rel[len("res/img/"):]
|
||||
elif rel.startswith("res/"):
|
||||
rel = rel[len("res/"):]
|
||||
elif rel.startswith("img/"):
|
||||
rel = rel[len("img/"):]
|
||||
base = (basic_path / "res" / "img").resolve()
|
||||
target = (basic_path / "res" / "img" / path).resolve()
|
||||
target = (basic_path / "res" / "img" / rel).resolve()
|
||||
if not target.is_relative_to(base) or not target.exists():
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
return FileResponse(target)
|
||||
@@ -96,13 +105,11 @@ def build_admin_app() -> FastAPI | None:
|
||||
async def api_upload_icon(seed: int, request: Request, _: dict = auth):
|
||||
body = await _get_body(request)
|
||||
svg = (body.get("svg") or "").strip()
|
||||
framed = bool(body.get("framed"))
|
||||
item = get_stratagem(seed)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="未找到该战备")
|
||||
frame_color = TYPE_COLORS.get(item.get("type"), "#6bb7ea")
|
||||
try:
|
||||
png = await svg_to_png(svg, framed=framed, frame_color=frame_color)
|
||||
png = await svg_to_png(svg)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user