From 0108a5f63cb91a2b979d826dad07836a11ee8d61 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 07:41:41 +0800 Subject: [PATCH 1/8] feat: register recoverable meme workbench panel --- dashboard.py | 142 +++++++++++++----- dashboard_panel.tsx | 180 ----------------------- plugin.py | 6 + tests/test_plugin.py | 100 ++++++++++++- dashboard_panel.css => web_module.css | 41 +++--- web_module.js | 200 ++++++++++++++++++++++++++ 6 files changed, 430 insertions(+), 239 deletions(-) delete mode 100644 dashboard_panel.tsx rename dashboard_panel.css => web_module.css (92%) create mode 100644 web_module.js diff --git a/dashboard.py b/dashboard.py index fc996a0..c2c5bbe 100644 --- a/dashboard.py +++ b/dashboard.py @@ -1,6 +1,9 @@ from __future__ import annotations +import json import os +from pathlib import Path +from uuid import uuid4 from typing import Any from fastapi import FastAPI, HTTPException @@ -19,12 +22,14 @@ def get_meme_categories() -> dict[str, Any]: catalog._load() result: list[dict[str, Any]] = [] for tag, cat in catalog._categories.items(): - cat_dir = memes_dir / tag + cat_dir = _safe_path(memes_dir, ((tag, "category"),)) count = 0 if cat_dir.is_dir(): count = len([ f for f in cat_dir.iterdir() - if f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} + if not f.is_symlink() + and f.is_file() + and f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} ]) result.append({ "tag": tag, @@ -39,57 +44,124 @@ def get_meme_categories() -> dict[str, Any]: @app.get("/api/dashboard/meme/images/{tag}") def get_meme_images(tag: str) -> dict[str, Any]: - cat_dir = memes_dir / tag + cat_dir = _safe_path(memes_dir, ((tag, "category"),)) images = [] if cat_dir.is_dir(): images = [ f.name for f in cat_dir.iterdir() - if f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} + if not f.is_symlink() + and f.is_file() + and f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} ] images.sort() return {"tag": tag, "images": images} @app.delete("/api/dashboard/meme/categories/{tag}") def delete_meme_category(tag: str) -> dict[str, Any]: - catalog._load() - if tag not in catalog._categories: - raise HTTPException(status_code=404, detail="Category not found") - del catalog._categories[tag] - - manifest_path = memes_dir / "manifest.json" - if manifest_path.exists(): - import json - try: - data = json.loads(manifest_path.read_text(encoding="utf-8")) - except Exception: - data = {} - if "categories" in data and tag in data["categories"]: - del data["categories"][tag] - manifest_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - - import shutil - cat_dir = memes_dir / tag - if cat_dir.is_dir(): - shutil.rmtree(cat_dir, ignore_errors=True) - - return {"success": True} + return _remove_category(memes_dir, tag, catalog) @app.delete("/api/dashboard/meme/media/{tag}/{filename}") def delete_meme_media(tag: str, filename: str) -> dict[str, Any]: - safe_tag = os.path.basename(tag) - safe_filename = os.path.basename(filename) - file_path = memes_dir / safe_tag / safe_filename + file_path = _safe_path( + memes_dir, + ((tag, "category"), (filename, "filename")), + ) if not file_path.is_file(): raise HTTPException(status_code=404, detail="Meme image not found") - file_path.unlink() - return {"success": True} + recovery_id = _move_to_recovery(memes_dir, file_path, "image") + return {"success": True, "recovery_id": recovery_id} @app.get("/api/dashboard/meme/media/{tag}/{filename}") def get_meme_media(tag: str, filename: str) -> Any: - # Avoid path traversal attacks - safe_tag = os.path.basename(tag) - safe_filename = os.path.basename(filename) - file_path = memes_dir / safe_tag / safe_filename + file_path = _safe_path( + memes_dir, + ((tag, "category"), (filename, "filename")), + ) if not file_path.is_file(): raise HTTPException(status_code=404, detail="Meme image not found") return FileResponse(file_path) + + +def _safe_segment(value: str, label: str) -> str: + if ( + not value + or value in {".", ".."} + or "/" in value + or "\\" in value + or "\x00" in value + or os.path.basename(value) != value + ): + raise HTTPException(status_code=422, detail=f"Invalid {label}") + return value + + +def _safe_path(root: Path, segments: tuple[tuple[str, str], ...]) -> Path: + base = root.resolve(strict=False) + candidate = base + for value, label in segments: + candidate /= _safe_segment(value, label) + if candidate.is_symlink(): + raise HTTPException(status_code=422, detail=f"Invalid {label}") + if not candidate.resolve(strict=False).is_relative_to(base): + raise HTTPException(status_code=422, detail="Path escapes meme workspace") + return candidate + + +def _move_to_recovery(memes_dir: Path, source: Path, kind: str) -> str: + recovery_id = f"{kind}-{uuid4().hex}" + recovery_dir = _safe_path(memes_dir, ((".trash", "recovery root"),)) / recovery_id + try: + recovery_dir.mkdir(parents=True, exist_ok=False) + source.replace(recovery_dir / source.name) + except OSError as error: + raise HTTPException(status_code=500, detail=f"Failed to preserve deleted {kind}") from error + return recovery_id + + +def _remove_category( + memes_dir: Path, + tag: str, + catalog: MemeCatalog, +) -> dict[str, object]: + manifest_path = _safe_path(memes_dir, (("manifest.json", "manifest"),)) + category_dir = _safe_path(memes_dir, ((tag, "category"),)) + try: + manifest_bytes = manifest_path.read_bytes() + manifest = json.loads(manifest_bytes) + except (OSError, json.JSONDecodeError) as error: + raise HTTPException(status_code=500, detail="Meme manifest is unreadable") from error + categories = manifest.get("categories") if isinstance(manifest, dict) else None + if not isinstance(categories, dict): + raise HTTPException(status_code=500, detail="Meme manifest categories are invalid") + if tag not in categories: + raise HTTPException(status_code=404, detail="Category not found") + + recovery_id = f"category-{uuid4().hex}" + recovery_dir = _safe_path(memes_dir, ((".trash", "recovery root"),)) / recovery_id + moved_category: Path | None = None + try: + recovery_dir.mkdir(parents=True, exist_ok=False) + (recovery_dir / "manifest.json").write_bytes(manifest_bytes) + if category_dir.exists(): + moved_category = recovery_dir / tag + category_dir.replace(moved_category) + del categories[tag] + _write_manifest(manifest_path, manifest) + except OSError as error: + if moved_category is not None and moved_category.exists(): + moved_category.replace(category_dir) + raise HTTPException(status_code=500, detail="Failed to preserve deleted category") from error + catalog._manifest_mtime = -1.0 + return {"success": True, "recovery_id": recovery_id} + + +def _write_manifest(path: Path, manifest: dict[str, object]) -> None: + staging = path.with_name(f".{path.name}.{uuid4().hex}.tmp") + try: + with staging.open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, ensure_ascii=False) + handle.flush() + os.fsync(handle.fileno()) + os.replace(staging, path) + finally: + staging.unlink(missing_ok=True) diff --git a/dashboard_panel.tsx b/dashboard_panel.tsx deleted file mode 100644 index f31d2d2..0000000 --- a/dashboard_panel.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/// -import { useEffect, useState } from "react"; -import { api } from "@akashic/dashboard-ui"; - -// The Akashic Dashboard injects itself globally. -// We declare it here to satisfy TypeScript in our standalone build. -declare global { - interface Window { - AkashicDashboard: any; - } -} - -interface Category { - tag: string; - name: string; - desc: string; - aliases: string[]; - enabled: boolean; - count: number; -} - -function MemeMain() { - const [categories, setCategories] = useState([]); - const [selectedTag, setSelectedTag] = useState(null); - const [images, setImages] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - void api("/api/dashboard/meme/categories").then((data: any) => { - setCategories(data.categories || []); - if (data.categories?.length > 0) { - setSelectedTag(data.categories[0].tag); - } - setLoading(false); - }, (reason: unknown) => { - setError(reason instanceof Error ? reason.message : "分类读取失败"); - setLoading(false); - }); - }, []); - - useEffect(() => { - if (selectedTag) { - void api(`/api/dashboard/meme/images/${selectedTag}`).then((data: any) => { - setImages(data.images || []); - }, (reason: unknown) => setError(reason instanceof Error ? reason.message : "图片读取失败")); - } else { - setImages([]); - } - }, [selectedTag]); - - return ( -
- - -
-
-
-

表情包库

-

- {selectedTag ? categories.find((c) => c.tag === selectedTag)?.name || selectedTag : "选择一个分类"} -

-
- {selectedTag ? categories.find((c) => c.tag === selectedTag)?.desc : ""} -
-
- {images.length} 张图片 -
- -
- {images.map(img => ( -
- -
- -
-
- {img} -
-
- ))} - {loading &&
正在读取表情包分类…
} - {!loading && !error && images.length === 0 && selectedTag && ( -
- 这个分类还没有图片 - 通过 Meme 插件导入图片后会显示在这里。 -
- )} - {error && ( -
- 无法加载表情包 - {error} -
- )} -
-
-
- ); -} - -window.AkashicDashboard.registerPlugin({ - id: "meme", - label: "Meme 表情包", - viewLabel: "表情包", - layout: "workbench", - - async getCount(): Promise { - try { - const data = await api("/api/dashboard/meme/categories"); - let total = 0; - for (const cat of data.categories || []) { - total += cat.count; - } - return total; - } catch { - return null; - } - }, - - async fetchPage() { - return { items: [], total: 0 }; - }, - - Main: MemeMain, -}); diff --git a/plugin.py b/plugin.py index fb579f1..6f73152 100644 --- a/plugin.py +++ b/plugin.py @@ -46,6 +46,12 @@ def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None: inject: tuple[ServiceKey[object], ...] = (CITATION_PROTOCOL_SERVICE,) skill_roots = ("skills",) dashboard_module = "dashboard.py" +web_module = "web_module.js" +web_requires = ("workbench.panels.v1",) +web_provides = () +web_contract_digests = { + "workbench.panels.v1": "724b282c22c4b3f3a36967ab664c4dfd8bce4257665f99459000306938caf527", +} workspace_roots = ("memes",) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 76fd844..a57e091 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone import pytest -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.routing import APIRoute from agent.core.response_parser import ResponseMetadata @@ -118,7 +118,6 @@ def _prompt_ctx() -> PromptRenderCtx: timestamp=datetime.now(timezone.utc), history=[], skill_names=[], - retrieved_memory_block="", disabled_sections=set(), turn_injection_prompt="", ) @@ -205,6 +204,7 @@ async def mount(ctx: Context) -> None: inject=inject, runtime=PluginRuntime( plugin_id="meme", + generation_id=root.generation_id, plugin_dir=Path(__file__).parents[1], data_dir=tmp_path / "plugin-data", workspace=tmp_path, @@ -269,6 +269,7 @@ async def mount(ctx: Context) -> None: inject=inject, runtime=PluginRuntime( plugin_id="meme", + generation_id=root.generation_id, plugin_dir=Path(__file__).parents[1], data_dir=tmp_path / "candidate-data", workspace=candidate_workspace, @@ -315,6 +316,101 @@ async def mount(ctx: Context) -> None: await root.dispose() +def _meme_dashboard_route(tmp_path: Path, path: str, method: str): + dashboard_module = importlib.import_module("test_meme_plugin.dashboard") + app = FastAPI() + dashboard_module.register( + app, + DashboardContext( + plugin_id="meme", + plugin_dir=Path(__file__).parents[1], + data_root=tmp_path / "data", + validation=True, + _workspace_roots=(("memes", tmp_path / "memes"),), + ), + ) + return next( + route + for route in app.routes + if isinstance(route, APIRoute) + and route.path == path + and method in route.methods + ) + + +def test_dashboard_rejects_media_path_traversal(tmp_path: Path) -> None: + media_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "GET", + ) + + with pytest.raises(HTTPException, match="Invalid category") as raised: + media_route.endpoint("..", "secret.png") + + assert raised.value.status_code == 422 + + +def test_dashboard_rejects_media_symlink(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + secret = tmp_path / "secret.png" + secret.write_bytes(b"secret") + image.unlink() + image.symlink_to(secret) + media_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "GET", + ) + + with pytest.raises(HTTPException, match="Invalid filename") as raised: + media_route.endpoint("shy", "001.png") + + assert raised.value.status_code == 422 + + +def test_dashboard_delete_keeps_named_recovery_copy(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + delete_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "DELETE", + ) + + result = delete_route.endpoint("shy", "001.png") + + assert not image.exists() + recovery_id = result["recovery_id"] + assert recovery_id.startswith("image-") + recovered = tmp_path / "memes" / ".trash" / recovery_id / "001.png" + assert recovered.read_bytes() == b"\x89PNG\r\n\x1a\n" + + +def test_category_delete_restores_directory_when_manifest_write_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image = _write_meme_workspace(tmp_path) + dashboard_module = importlib.import_module("test_meme_plugin.dashboard") + + def fail_write(*_args) -> None: + raise OSError("disk full") + + monkeypatch.setattr(dashboard_module, "_write_manifest", fail_write) + delete_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/categories/{tag}", + "DELETE", + ) + + with pytest.raises(HTTPException, match="Failed to preserve deleted category"): + delete_route.endpoint("shy") + + assert image.is_file() + manifest = json.loads((tmp_path / "memes" / "manifest.json").read_text()) + assert "shy" in manifest["categories"] + + @pytest.mark.asyncio async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( tmp_path: Path, diff --git a/dashboard_panel.css b/web_module.css similarity index 92% rename from dashboard_panel.css rename to web_module.css index 4e2fe95..0cd1b74 100644 --- a/dashboard_panel.css +++ b/web_module.css @@ -1,12 +1,3 @@ -.plugin-workbench-pane:has(> .plugin-workbench-root[data-akashic-plugin="meme"]) { - overflow: hidden; -} - -.plugin-workbench-root[data-akashic-plugin="meme"] { - height: 100%; - min-height: 0; -} - .meme-dashboard { display: grid; grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); @@ -90,10 +81,6 @@ text-align: start; } -.meme-category:hover { - background: var(--ak-color-bg-surface-high); -} - .meme-category.is-active { color: var(--ak-color-action-primary); background: var(--ak-color-action-soft); @@ -137,20 +124,14 @@ .meme-delete, .meme-item__delete { display: grid; - width: 40px; - height: 40px; + width: 44px; + height: 44px; place-items: center; border-radius: 6px; color: var(--ak-color-text-muted); font-size: 14px; } -.meme-delete:hover, -.meme-item__delete:hover { - color: var(--ak-color-status-error); - background: color-mix(in oklch, var(--ak-color-status-error) 12%, transparent); -} - .meme-dashboard button:focus-visible, .meme-dashboard summary:focus-visible { outline: 2px solid var(--ak-color-action-primary); @@ -193,6 +174,9 @@ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); align-content: start; gap: 16px; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; } .meme-item { @@ -234,12 +218,24 @@ overflow: hidden; margin-block-start: 8px; color: var(--ak-color-text-muted); - font-family: var(--mono); + font-family: var(--ak-type-technical); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +@media (hover: hover) { + .meme-category:hover { + background: var(--ak-color-bg-surface-high); + } + + .meme-delete:hover, + .meme-item__delete:hover { + color: var(--ak-color-status-error); + background: color-mix(in oklch, var(--ak-color-status-error) 12%, transparent); + } +} + .meme-state { display: grid; grid-column: 1 / -1; @@ -312,6 +308,7 @@ .meme-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; + overflow: visible; } } diff --git a/web_module.js b/web_module.js new file mode 100644 index 0000000..85adfea --- /dev/null +++ b/web_module.js @@ -0,0 +1,200 @@ +export function activate(ctx) { + return ctx.ui.inject("workbench.panels.v1", (mount) => mount.register({ + id: "meme", + label: "Meme 表情包", + order: 40, + render(host) { + const panel = document.createElement("section"); + panel.className = "meme-dashboard"; + panel.innerHTML = ``; + host.replaceChildren(panel); + const categoriesHost = panel.querySelector("[data-categories]"); + const categoryCount = panel.querySelector("[data-category-count]"); + const title = panel.querySelector("[data-title]"); + const description = panel.querySelector("[data-description]"); + const total = panel.querySelector("[data-total]"); + const status = panel.querySelector("[data-status]"); + const gallery = panel.querySelector("[data-gallery]"); + let categories = []; + let selectedTag = ""; + let catalogRequest = new AbortController(); + let imageRequest = new AbortController(); + let mutationRequest = new AbortController(); + let observer = null; + let objectUrls = []; + let disposed = false; + + const clearImages = () => { + imageRequest.abort(); + imageRequest = new AbortController(); + observer?.disconnect(); + observer = null; + for (const url of objectUrls) URL.revokeObjectURL(url); + objectUrls = []; + gallery.replaceChildren(); + }; + + const loadCategories = async (preferredTag = selectedTag) => { + catalogRequest.abort(); + catalogRequest = new AbortController(); + const activeRequest = catalogRequest; + status.textContent = "正在读取表情包分类…"; + try { + const result = await json(ctx, "/api/dashboard/meme/categories", {}, activeRequest.signal); + if (disposed || activeRequest.signal.aborted) return; + categories = Array.isArray(result.categories) ? result.categories : []; + selectedTag = categories.some((item) => item.tag === preferredTag) + ? preferredTag + : String(categories[0]?.tag ?? ""); + renderCategories(); + if (selectedTag) await loadImages(selectedTag); + else { + clearImages(); + title.textContent = "选择一个分类"; + total.textContent = "0 张图片"; + status.textContent = "还没有表情包分类。"; + } + } catch (reason) { + if (!activeRequest.signal.aborted) showError(status, reason); + } + }; + + const renderCategories = () => { + categoryCount.textContent = String(categories.length); + categoriesHost.replaceChildren(); + for (const category of categories) { + const row = document.createElement("div"); + row.className = "meme-category-row"; + const choose = document.createElement("button"); + choose.type = "button"; + choose.className = `meme-category${selectedTag === category.tag ? " is-active" : ""}`; + choose.disabled = !category.enabled; + choose.setAttribute("aria-current", selectedTag === category.tag ? "page" : "false"); + choose.innerHTML = `${escapeHtml(category.name || category.tag)}${escapeHtml(category.desc || category.tag)}${Number(category.count || 0)}`; + choose.addEventListener("click", () => { + selectedTag = category.tag; + renderCategories(); + void loadImages(category.tag); + }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "meme-delete"; + remove.setAttribute("aria-label", `删除分类 ${category.tag}`); + remove.textContent = "×"; + remove.addEventListener("click", () => void deleteCategory(category)); + row.append(choose, remove); + categoriesHost.append(row); + } + }; + + const loadImages = async (tag) => { + clearImages(); + const activeRequest = imageRequest; + const category = categories.find((item) => item.tag === tag); + title.textContent = category?.name || tag; + description.textContent = category?.desc || ""; + status.textContent = "正在读取图片…"; + try { + const result = await json(ctx, `/api/dashboard/meme/images/${encodeURIComponent(tag)}`, {}, activeRequest.signal); + if (disposed || activeRequest.signal.aborted || tag !== selectedTag) return; + const images = Array.isArray(result.images) ? result.images : []; + total.textContent = `${images.length} 张图片`; + status.textContent = images.length ? "" : "这个分类还没有图片。"; + observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + observer?.unobserve(entry.target); + void loadImage(entry.target, tag, activeRequest.signal); + } + }, {rootMargin: "160px"}); + for (const filename of images) { + const figure = document.createElement("figure"); + figure.className = "meme-item"; + figure.dataset.filename = filename; + figure.innerHTML = `
载入中
${escapeHtml(filename)}
`; + figure.querySelector("button").addEventListener("click", () => void deleteImage(tag, filename)); + gallery.append(figure); + observer.observe(figure); + } + } catch (reason) { + if (!activeRequest.signal.aborted) showError(status, reason); + } + }; + + const loadImage = async (figure, tag, signal) => { + try { + const response = await ctx.http.request(`/api/dashboard/meme/media/${encodeURIComponent(tag)}/${encodeURIComponent(figure.dataset.filename)}`, {signal}); + if (!response.ok) throw new Error(`图片读取失败: HTTP ${response.status}`); + const url = URL.createObjectURL(await response.blob()); + if (disposed || signal.aborted || tag !== selectedTag) { + URL.revokeObjectURL(url); + return; + } + objectUrls.push(url); + const image = document.createElement("img"); + image.src = url; + image.alt = ""; + figure.querySelector(".meme-item__preview").replaceChildren(image); + } catch (reason) { + if (!signal.aborted) figure.querySelector(".meme-item__preview").textContent = reason instanceof Error ? reason.message : String(reason); + } + }; + + const deleteCategory = async (category) => { + if (!window.confirm(`确定删除分类“${category.tag}”及其中全部图片吗?此操作无法撤销。`)) return; + mutationRequest.abort(); + mutationRequest = new AbortController(); + const activeRequest = mutationRequest; + try { + await json(ctx, `/api/dashboard/meme/categories/${encodeURIComponent(category.tag)}`, {method: "DELETE"}, activeRequest.signal); + if (disposed || activeRequest.signal.aborted) return; + await loadCategories(); + } catch (reason) { + if (!activeRequest.signal.aborted) showError(status, reason); + } + }; + + const deleteImage = async (tag, filename) => { + if (!window.confirm(`确定删除图片“${filename}”吗?此操作无法撤销。`)) return; + mutationRequest.abort(); + mutationRequest = new AbortController(); + const activeRequest = mutationRequest; + try { + await json(ctx, `/api/dashboard/meme/media/${encodeURIComponent(tag)}/${encodeURIComponent(filename)}`, {method: "DELETE"}, activeRequest.signal); + if (disposed || activeRequest.signal.aborted || tag !== selectedTag) return; + const category = categories.find((item) => item.tag === tag); + if (category) category.count = Math.max(0, Number(category.count) - 1); + renderCategories(); + await loadImages(tag); + } catch (reason) { + if (!activeRequest.signal.aborted) showError(status, reason); + } + }; + + void loadCategories(); + return () => { + disposed = true; + catalogRequest.abort(); + mutationRequest.abort(); + clearImages(); + host.replaceChildren(); + }; + }, + })); +} + +async function json(ctx, path, init, signal) { + const response = await ctx.http.request(path, {...init, signal}); + const body = await response.json(); + if (!response.ok) throw new Error(body?.detail || body?.message || `HTTP ${response.status}`); + return body; +} + +function showError(target, reason) { + target.setAttribute("role", "alert"); + target.textContent = reason instanceof Error ? reason.message : String(reason); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (character) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[character]); +} From c57a69cb31efd1f4628f33e3cbbd686bfb26c4f1 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 08:35:19 +0800 Subject: [PATCH 2/8] ci: validate Web UI composition --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 392cdec..300a9f0 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 3005f838bcd96e2cbc58616aede46e4f39df4523 + ref: 14221ff778d660ab0e9c9249a8a8ff772861dbe9 path: .akashic-core - uses: actions/checkout@v4 with: From 7d12cadb610de70c3ec0edf439c04d4768a7bdcd Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 08:41:50 +0800 Subject: [PATCH 3/8] ci: follow final Core head --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 300a9f0..79b5f19 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 14221ff778d660ab0e9c9249a8a8ff772861dbe9 + ref: a4e87422a2448c4b148b03777da349b51c77ed16 path: .akashic-core - uses: actions/checkout@v4 with: From 7dcd3a9ddd955ca97b7bab9951755885dcfb9ec2 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 08:47:29 +0800 Subject: [PATCH 4/8] ci: validate final Core release --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 79b5f19..a29849c 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: a4e87422a2448c4b148b03777da349b51c77ed16 + ref: f72b9e2ce2031133462e68aeaa0379fa883daeb2 path: .akashic-core - uses: actions/checkout@v4 with: From 01139a3fa5c7c1e2fb18536bc75cbb6ac0f720ea Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 09:00:07 +0800 Subject: [PATCH 5/8] ci: validate deployed Core release --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index a29849c..abd030f 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: f72b9e2ce2031133462e68aeaa0379fa883daeb2 + ref: 69a9616f6f48f19dc109a6b3eef8fc1000825829 path: .akashic-core - uses: actions/checkout@v4 with: From 7204afe826ae84aefd00b58e6461b5181d896d45 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 19:17:44 +0800 Subject: [PATCH 6/8] fix(web-ui): compose meme panel through workbench --- akashic.plugin.toml | 2 +- dashboard_panel.css | 316 ++++++++++++++++++++++++++++++++++ dashboard_panel.tsx | 234 +++++++++++++++++++++++++ plugin.py | 6 +- tests/test_plugin.py | 2 +- web_module.css | 73 ++------ web_module.js | 398 ++++++++++++++++++++++--------------------- 7 files changed, 770 insertions(+), 261 deletions(-) create mode 100644 dashboard_panel.css create mode 100644 dashboard_panel.tsx diff --git a/akashic.plugin.toml b/akashic.plugin.toml index 7f4da6f..ecf78fa 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,5 +1,5 @@ schema_version = 1 name = "meme" -version = "1.0.0" +version = "1.0.1" api_version = 3 entrypoint = "plugin.py" diff --git a/dashboard_panel.css b/dashboard_panel.css new file mode 100644 index 0000000..8098bc0 --- /dev/null +++ b/dashboard_panel.css @@ -0,0 +1,316 @@ +.meme-dashboard { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + flex: 1; + height: 100%; + min-height: 0; + gap: 28px; + overflow: hidden; + padding: 24px; + color: var(--ak-color-text-primary); + background: var(--ak-color-bg-canvas); +} + +.meme-dashboard__sidebar { + display: flex; + min-height: 0; + flex-direction: column; + gap: 6px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + padding-inline-end: 16px; + border-inline-end: 1px solid var(--ak-color-border-default); +} + +.meme-dashboard__sidebar-heading, +.meme-dashboard__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; +} + +.meme-dashboard__sidebar-heading { + min-height: 40px; + margin-block-end: 6px; +} + +.meme-dashboard__sidebar-heading h2, +.meme-dashboard__header h1 { + margin: 0; + color: var(--ak-color-text-primary); + letter-spacing: -0.02em; +} + +.meme-dashboard__sidebar-heading h2 { + font-size: 15px; +} + +.meme-dashboard__sidebar-heading span, +.meme-dashboard__total { + color: var(--ak-color-text-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.meme-category-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 4px; +} + +.meme-category, +.meme-delete, +.meme-item__delete { + border: 0; + color: inherit; + background: transparent; + cursor: pointer; +} + +.meme-category { + display: flex; + min-width: 0; + min-height: 60px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 6px; + text-align: start; +} + +.meme-category:hover { + background: var(--ak-color-bg-surface-high); +} + +.meme-category.is-active { + color: var(--ak-color-action-primary); + background: var(--ak-color-action-soft); + box-shadow: inset 3px 0 0 var(--ak-color-action-primary); +} + +.meme-category:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.meme-category > span { + display: grid; + min-width: 0; + gap: 3px; +} + +.meme-category strong, +.meme-category small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meme-category strong { + font-size: 14px; + font-weight: 650; +} + +.meme-category small { + color: var(--ak-color-text-muted); + font-size: 12px; +} + +.meme-category b { + color: var(--ak-color-text-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.meme-delete, +.meme-item__delete { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border-radius: 6px; + color: var(--ak-color-text-muted); + font-size: 14px; +} + +.meme-delete:hover, +.meme-item__delete:hover { + color: var(--ak-color-status-error); + background: color-mix(in oklch, var(--ak-color-status-error) 12%, transparent); +} + +.meme-dashboard button:focus-visible, +.meme-dashboard summary:focus-visible { + outline: 2px solid var(--ak-color-action-primary); + outline-offset: 2px; +} + +.meme-dashboard__gallery { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +.meme-dashboard__header { + margin-bottom: 20px; +} + +.meme-dashboard__header p { + margin: 0 0 5px; + color: var(--ak-color-action-primary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.09em; +} + +.meme-dashboard__header h1 { + font-size: clamp(22px, 2vw, 28px); +} + +.meme-dashboard__description { + margin-block-start: 6px; + color: var(--ak-color-text-muted); + font-size: 13px; +} + +.meme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + align-content: start; + gap: 16px; +} + +.meme-item { + position: relative; + min-width: 0; + margin: 0; + padding: 8px; + border: 1px solid var(--ak-color-border-default); + border-radius: 7px; + background: var(--ak-color-bg-surface); +} + +.meme-item__preview { + display: grid; + aspect-ratio: 1; + place-items: center; + overflow: hidden; + border-radius: 4px; + background: var(--ak-color-bg-surface-low); +} + +.meme-item__preview img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.meme-item__delete { + position: absolute; + z-index: 1; + top: 12px; + right: 12px; + border: 1px solid var(--ak-color-border-default); + background: var(--ak-color-bg-surface); +} + +.meme-item figcaption { + overflow: hidden; + margin-block-start: 8px; + color: var(--ak-color-text-muted); + font-family: var(--mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meme-state { + display: grid; + grid-column: 1 / -1; + min-height: 180px; + place-content: center; + gap: 6px; + padding: 24px; + border: 1px dashed var(--ak-color-border-strong); + border-radius: 7px; + color: var(--ak-color-text-muted); + text-align: center; +} + +.meme-state strong { + color: var(--ak-color-text-primary); +} + +.meme-state.is-error { + border-color: color-mix(in oklch, var(--ak-color-status-error) 40%, var(--ak-color-border-default)); +} + +.meme-state.is-error strong { + color: var(--ak-color-status-error); +} + +.meme-dashboard__nav-empty { + margin: 8px 12px; + color: var(--ak-color-text-muted); + font-size: 13px; +} + +@media (max-width: 720px) { + .meme-dashboard { + display: block; + overflow-y: auto; + padding: 16px; + } + + .meme-dashboard__sidebar { + display: grid; + grid-auto-columns: minmax(180px, 72vw); + grid-auto-flow: column; + gap: 8px; + overflow-x: auto; + overflow-y: hidden; + margin-block-end: 24px; + padding: 0 0 12px; + border-inline-end: 0; + border-block-end: 1px solid var(--ak-color-border-default); + } + + .meme-dashboard__sidebar-heading, + .meme-dashboard__nav-empty { + grid-column: 1; + } + + .meme-category-row { + grid-row: 2; + } + + .meme-dashboard__gallery { + overflow: visible; + } + + .meme-dashboard__header { + align-items: flex-start; + flex-wrap: wrap; + } + + .meme-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } +} + +@media (prefers-reduced-motion: reduce) { + .meme-dashboard *, + .meme-dashboard *::before, + .meme-dashboard *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/dashboard_panel.tsx b/dashboard_panel.tsx new file mode 100644 index 0000000..c30302a --- /dev/null +++ b/dashboard_panel.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState } from "react"; +import "./dashboard_panel.css"; +import type { WebHostContextV1, WebUiDisposer } from "@akashic/web-ui-v1"; +import type { WorkbenchPanelEntry } from "@akashic/workbench-ui-v2"; + +let dashboardRequest: WebHostContextV1["http"]["request"] | null = null; + +async function api(path: string, init?: RequestInit): Promise { + if (!dashboardRequest) throw new Error("Meme 工作台面板未激活"); + const response = await dashboardRequest(path, init); + const body = await response.json() as T & { detail?: unknown; message?: unknown }; + if (!response.ok) throw new Error(String(body.detail ?? body.message ?? `HTTP ${response.status}`)); + return body; +} + +async function media(path: string, signal: AbortSignal): Promise { + if (!dashboardRequest) throw new Error("Meme 工作台面板未激活"); + const response = await dashboardRequest(path, { signal }); + if (!response.ok) throw new Error(`图片读取失败:HTTP ${response.status}`); + return response.blob(); +} + +interface Category { + tag: string; + name: string; + desc: string; + aliases: string[]; + enabled: boolean; + count: number; +} + +function MemeMain() { + const [categories, setCategories] = useState([]); + const [selectedTag, setSelectedTag] = useState(null); + const [images, setImages] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const controller = new AbortController(); + void api("/api/dashboard/meme/categories", { signal: controller.signal }).then((data: any) => { + if (controller.signal.aborted) return; + setCategories(data.categories || []); + if (data.categories?.length > 0) { + setSelectedTag(data.categories[0].tag); + } + setLoading(false); + }, (reason: unknown) => { + if (controller.signal.aborted) return; + setError(reason instanceof Error ? reason.message : "分类读取失败"); + setLoading(false); + }); + return () => controller.abort(); + }, []); + + useEffect(() => { + if (!selectedTag) { + setImages([]); + return; + } + const controller = new AbortController(); + setImages([]); + setError(null); + void api(`/api/dashboard/meme/images/${selectedTag}`, { signal: controller.signal }).then((data: any) => { + if (!controller.signal.aborted) setImages(data.images || []); + }, (reason: unknown) => { + if (!controller.signal.aborted) { + setError(reason instanceof Error ? reason.message : "图片读取失败"); + } + }); + return () => controller.abort(); + }, [selectedTag]); + + return ( +
+ + +
+
+
+

表情包库

+

+ {selectedTag ? categories.find((c) => c.tag === selectedTag)?.name || selectedTag : "选择一个分类"} +

+
+ {selectedTag ? categories.find((c) => c.tag === selectedTag)?.desc : ""} +
+
+ {images.length} 张图片 +
+ +
+ {images.map(img => ( +
+ +
+
+ {img} +
+
+ ))} + {loading &&
正在读取表情包分类…
} + {!loading && !error && images.length === 0 && selectedTag && ( +
+ 这个分类还没有图片 + 通过 Meme 插件导入图片后会显示在这里。 +
+ )} + {error && ( +
+ 无法加载表情包 + {error} +
+ )} +
+
+
+ ); +} + +function MemeImage({ tag, name }: { tag: string; name: string }) { + const [source, setSource] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + let objectUrl: string | null = null; + void media(`/api/dashboard/meme/media/${tag}/${name}`, controller.signal).then((blob) => { + objectUrl = URL.createObjectURL(blob); + setSource(objectUrl); + }, (reason: unknown) => { + if (!controller.signal.aborted) console.error("Meme 图片读取失败", reason); + }); + return () => { + controller.abort(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [name, tag]); + + return ; +} + +const panel = { + id: "meme", + label: "Meme 表情包", + viewLabel: "表情包", + order: 50, + layout: "workbench", + rowKey: "id", + columns: [], + + async getCount({ signal }: { signal: AbortSignal }): Promise { + try { + const data = await api("/api/dashboard/meme/categories", { signal }); + let total = 0; + for (const cat of data.categories || []) { + total += cat.count; + } + return total; + } catch (error) { + if (signal.aborted) throw error; + return null; + } + }, + + async fetchPage() { + return { items: [], total: 0 }; + }, + + Main: MemeMain, +} satisfies WorkbenchPanelEntry; + +export function activate(ctx: WebHostContextV1): WebUiDisposer { + dashboardRequest = ctx.http.request; + const release = ctx.ui.inject("workbench.panels.v2", (mount) => mount.register(panel)); + return () => { + release(); + dashboardRequest = null; + }; +} diff --git a/plugin.py b/plugin.py index 6f73152..c33eb9f 100644 --- a/plugin.py +++ b/plugin.py @@ -42,15 +42,15 @@ def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None: api_version = 3 name = "meme" -version = "1.0.0" +version = "1.0.1" inject: tuple[ServiceKey[object], ...] = (CITATION_PROTOCOL_SERVICE,) skill_roots = ("skills",) dashboard_module = "dashboard.py" web_module = "web_module.js" -web_requires = ("workbench.panels.v1",) +web_requires = ("workbench.panels.v2",) web_provides = () web_contract_digests = { - "workbench.panels.v1": "724b282c22c4b3f3a36967ab664c4dfd8bce4257665f99459000306938caf527", + "workbench.panels.v2": "17a005a381b362ae25a0499dbf95bf7a2c3ff0bb4e9b415e7357db458de6b5db", } workspace_roots = ("memes",) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index a57e091..2346682 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -103,7 +103,7 @@ def test_static_manifest_matches_v3_module() -> None: ) assert manifest.name == _meme_plugin_module.name == "meme" - assert manifest.version == _meme_plugin_module.version == "1.0.0" + assert manifest.version == _meme_plugin_module.version == "1.0.1" assert manifest.api_version == _meme_plugin_module.api_version == 3 assert manifest.entrypoint == "plugin.py" diff --git a/web_module.css b/web_module.css index 0cd1b74..e864f04 100644 --- a/web_module.css +++ b/web_module.css @@ -1,3 +1,4 @@ +/* dashboard_panel.css */ .meme-dashboard { display: grid; grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); @@ -10,7 +11,6 @@ color: var(--ak-color-text-primary); background: var(--ak-color-bg-canvas); } - .meme-dashboard__sidebar { display: flex; min-height: 0; @@ -22,7 +22,6 @@ padding-inline-end: 16px; border-inline-end: 1px solid var(--ak-color-border-default); } - .meme-dashboard__sidebar-heading, .meme-dashboard__header { display: flex; @@ -30,36 +29,30 @@ justify-content: space-between; gap: 12px; } - .meme-dashboard__sidebar-heading { min-height: 40px; margin-block-end: 6px; } - .meme-dashboard__sidebar-heading h2, .meme-dashboard__header h1 { margin: 0; color: var(--ak-color-text-primary); letter-spacing: -0.02em; } - .meme-dashboard__sidebar-heading h2 { font-size: 15px; } - .meme-dashboard__sidebar-heading span, .meme-dashboard__total { color: var(--ak-color-text-muted); font-size: 12px; font-variant-numeric: tabular-nums; } - .meme-category-row { display: grid; grid-template-columns: minmax(0, 1fr) 40px; gap: 4px; } - .meme-category, .meme-delete, .meme-item__delete { @@ -68,7 +61,6 @@ background: transparent; cursor: pointer; } - .meme-category { display: flex; min-width: 0; @@ -80,64 +72,62 @@ border-radius: 6px; text-align: start; } - +.meme-category:hover { + background: var(--ak-color-bg-surface-high); +} .meme-category.is-active { color: var(--ak-color-action-primary); background: var(--ak-color-action-soft); box-shadow: inset 3px 0 0 var(--ak-color-action-primary); } - .meme-category:disabled { cursor: not-allowed; opacity: 0.5; } - .meme-category > span { display: grid; min-width: 0; gap: 3px; } - .meme-category strong, .meme-category small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .meme-category strong { font-size: 14px; font-weight: 650; } - .meme-category small { color: var(--ak-color-text-muted); font-size: 12px; } - .meme-category b { color: var(--ak-color-text-muted); font-size: 12px; font-variant-numeric: tabular-nums; } - .meme-delete, .meme-item__delete { display: grid; - width: 44px; - height: 44px; + width: 40px; + height: 40px; place-items: center; border-radius: 6px; color: var(--ak-color-text-muted); font-size: 14px; } - +.meme-delete:hover, +.meme-item__delete:hover { + color: var(--ak-color-status-error); + background: color-mix(in oklch, var(--ak-color-status-error) 12%, transparent); +} .meme-dashboard button:focus-visible, .meme-dashboard summary:focus-visible { outline: 2px solid var(--ak-color-action-primary); outline-offset: 2px; } - .meme-dashboard__gallery { display: flex; min-width: 0; @@ -146,11 +136,9 @@ flex-direction: column; overflow: hidden; } - .meme-dashboard__header { margin-bottom: 20px; } - .meme-dashboard__header p { margin: 0 0 5px; color: var(--ak-color-action-primary); @@ -158,27 +146,20 @@ font-weight: 700; letter-spacing: 0.09em; } - .meme-dashboard__header h1 { font-size: clamp(22px, 2vw, 28px); } - .meme-dashboard__description { margin-block-start: 6px; color: var(--ak-color-text-muted); font-size: 13px; } - .meme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); align-content: start; gap: 16px; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; } - .meme-item { position: relative; min-width: 0; @@ -188,7 +169,6 @@ border-radius: 7px; background: var(--ak-color-bg-surface); } - .meme-item__preview { display: grid; aspect-ratio: 1; @@ -197,14 +177,12 @@ border-radius: 4px; background: var(--ak-color-bg-surface-low); } - .meme-item__preview img { display: block; width: 100%; height: 100%; object-fit: contain; } - .meme-item__delete { position: absolute; z-index: 1; @@ -213,29 +191,15 @@ border: 1px solid var(--ak-color-border-default); background: var(--ak-color-bg-surface); } - .meme-item figcaption { overflow: hidden; margin-block-start: 8px; color: var(--ak-color-text-muted); - font-family: var(--ak-type-technical); + font-family: var(--mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } - -@media (hover: hover) { - .meme-category:hover { - background: var(--ak-color-bg-surface-high); - } - - .meme-delete:hover, - .meme-item__delete:hover { - color: var(--ak-color-status-error); - background: color-mix(in oklch, var(--ak-color-status-error) 12%, transparent); - } -} - .meme-state { display: grid; grid-column: 1 / -1; @@ -248,32 +212,26 @@ color: var(--ak-color-text-muted); text-align: center; } - .meme-state strong { color: var(--ak-color-text-primary); } - .meme-state.is-error { border-color: color-mix(in oklch, var(--ak-color-status-error) 40%, var(--ak-color-border-default)); } - .meme-state.is-error strong { color: var(--ak-color-status-error); } - .meme-dashboard__nav-empty { margin: 8px 12px; color: var(--ak-color-text-muted); font-size: 13px; } - @media (max-width: 720px) { .meme-dashboard { display: block; overflow-y: auto; padding: 16px; } - .meme-dashboard__sidebar { display: grid; grid-auto-columns: minmax(180px, 72vw); @@ -286,32 +244,25 @@ border-inline-end: 0; border-block-end: 1px solid var(--ak-color-border-default); } - .meme-dashboard__sidebar-heading, .meme-dashboard__nav-empty { grid-column: 1; } - .meme-category-row { grid-row: 2; } - .meme-dashboard__gallery { overflow: visible; } - .meme-dashboard__header { align-items: flex-start; flex-wrap: wrap; } - .meme-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; - overflow: visible; } } - @media (prefers-reduced-motion: reduce) { .meme-dashboard *, .meme-dashboard *::before, diff --git a/web_module.js b/web_module.js index 85adfea..fed1865 100644 --- a/web_module.js +++ b/web_module.js @@ -1,200 +1,208 @@ -export function activate(ctx) { - return ctx.ui.inject("workbench.panels.v1", (mount) => mount.register({ - id: "meme", - label: "Meme 表情包", - order: 40, - render(host) { - const panel = document.createElement("section"); - panel.className = "meme-dashboard"; - panel.innerHTML = ``; - host.replaceChildren(panel); - const categoriesHost = panel.querySelector("[data-categories]"); - const categoryCount = panel.querySelector("[data-category-count]"); - const title = panel.querySelector("[data-title]"); - const description = panel.querySelector("[data-description]"); - const total = panel.querySelector("[data-total]"); - const status = panel.querySelector("[data-status]"); - const gallery = panel.querySelector("[data-gallery]"); - let categories = []; - let selectedTag = ""; - let catalogRequest = new AbortController(); - let imageRequest = new AbortController(); - let mutationRequest = new AbortController(); - let observer = null; - let objectUrls = []; - let disposed = false; - - const clearImages = () => { - imageRequest.abort(); - imageRequest = new AbortController(); - observer?.disconnect(); - observer = null; - for (const url of objectUrls) URL.revokeObjectURL(url); - objectUrls = []; - gallery.replaceChildren(); - }; - - const loadCategories = async (preferredTag = selectedTag) => { - catalogRequest.abort(); - catalogRequest = new AbortController(); - const activeRequest = catalogRequest; - status.textContent = "正在读取表情包分类…"; - try { - const result = await json(ctx, "/api/dashboard/meme/categories", {}, activeRequest.signal); - if (disposed || activeRequest.signal.aborted) return; - categories = Array.isArray(result.categories) ? result.categories : []; - selectedTag = categories.some((item) => item.tag === preferredTag) - ? preferredTag - : String(categories[0]?.tag ?? ""); - renderCategories(); - if (selectedTag) await loadImages(selectedTag); - else { - clearImages(); - title.textContent = "选择一个分类"; - total.textContent = "0 张图片"; - status.textContent = "还没有表情包分类。"; - } - } catch (reason) { - if (!activeRequest.signal.aborted) showError(status, reason); - } - }; - - const renderCategories = () => { - categoryCount.textContent = String(categories.length); - categoriesHost.replaceChildren(); - for (const category of categories) { - const row = document.createElement("div"); - row.className = "meme-category-row"; - const choose = document.createElement("button"); - choose.type = "button"; - choose.className = `meme-category${selectedTag === category.tag ? " is-active" : ""}`; - choose.disabled = !category.enabled; - choose.setAttribute("aria-current", selectedTag === category.tag ? "page" : "false"); - choose.innerHTML = `${escapeHtml(category.name || category.tag)}${escapeHtml(category.desc || category.tag)}${Number(category.count || 0)}`; - choose.addEventListener("click", () => { - selectedTag = category.tag; - renderCategories(); - void loadImages(category.tag); - }); - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "meme-delete"; - remove.setAttribute("aria-label", `删除分类 ${category.tag}`); - remove.textContent = "×"; - remove.addEventListener("click", () => void deleteCategory(category)); - row.append(choose, remove); - categoriesHost.append(row); - } - }; - - const loadImages = async (tag) => { - clearImages(); - const activeRequest = imageRequest; - const category = categories.find((item) => item.tag === tag); - title.textContent = category?.name || tag; - description.textContent = category?.desc || ""; - status.textContent = "正在读取图片…"; - try { - const result = await json(ctx, `/api/dashboard/meme/images/${encodeURIComponent(tag)}`, {}, activeRequest.signal); - if (disposed || activeRequest.signal.aborted || tag !== selectedTag) return; - const images = Array.isArray(result.images) ? result.images : []; - total.textContent = `${images.length} 张图片`; - status.textContent = images.length ? "" : "这个分类还没有图片。"; - observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - if (!entry.isIntersecting) continue; - observer?.unobserve(entry.target); - void loadImage(entry.target, tag, activeRequest.signal); - } - }, {rootMargin: "160px"}); - for (const filename of images) { - const figure = document.createElement("figure"); - figure.className = "meme-item"; - figure.dataset.filename = filename; - figure.innerHTML = `
载入中
${escapeHtml(filename)}
`; - figure.querySelector("button").addEventListener("click", () => void deleteImage(tag, filename)); - gallery.append(figure); - observer.observe(figure); - } - } catch (reason) { - if (!activeRequest.signal.aborted) showError(status, reason); - } - }; - - const loadImage = async (figure, tag, signal) => { - try { - const response = await ctx.http.request(`/api/dashboard/meme/media/${encodeURIComponent(tag)}/${encodeURIComponent(figure.dataset.filename)}`, {signal}); - if (!response.ok) throw new Error(`图片读取失败: HTTP ${response.status}`); - const url = URL.createObjectURL(await response.blob()); - if (disposed || signal.aborted || tag !== selectedTag) { - URL.revokeObjectURL(url); - return; - } - objectUrls.push(url); - const image = document.createElement("img"); - image.src = url; - image.alt = ""; - figure.querySelector(".meme-item__preview").replaceChildren(image); - } catch (reason) { - if (!signal.aborted) figure.querySelector(".meme-item__preview").textContent = reason instanceof Error ? reason.message : String(reason); - } - }; - - const deleteCategory = async (category) => { - if (!window.confirm(`确定删除分类“${category.tag}”及其中全部图片吗?此操作无法撤销。`)) return; - mutationRequest.abort(); - mutationRequest = new AbortController(); - const activeRequest = mutationRequest; - try { - await json(ctx, `/api/dashboard/meme/categories/${encodeURIComponent(category.tag)}`, {method: "DELETE"}, activeRequest.signal); - if (disposed || activeRequest.signal.aborted) return; - await loadCategories(); - } catch (reason) { - if (!activeRequest.signal.aborted) showError(status, reason); - } - }; - - const deleteImage = async (tag, filename) => { - if (!window.confirm(`确定删除图片“${filename}”吗?此操作无法撤销。`)) return; - mutationRequest.abort(); - mutationRequest = new AbortController(); - const activeRequest = mutationRequest; - try { - await json(ctx, `/api/dashboard/meme/media/${encodeURIComponent(tag)}/${encodeURIComponent(filename)}`, {method: "DELETE"}, activeRequest.signal); - if (disposed || activeRequest.signal.aborted || tag !== selectedTag) return; - const category = categories.find((item) => item.tag === tag); - if (category) category.count = Math.max(0, Number(category.count) - 1); - renderCategories(); - await loadImages(tag); - } catch (reason) { - if (!activeRequest.signal.aborted) showError(status, reason); - } - }; - - void loadCategories(); - return () => { - disposed = true; - catalogRequest.abort(); - mutationRequest.abort(); - clearImages(); - host.replaceChildren(); - }; - }, - })); -} - -async function json(ctx, path, init, signal) { - const response = await ctx.http.request(path, {...init, signal}); +// dashboard_panel.tsx +import { useEffect, useState } from "react"; +import { jsx, jsxs } from "react/jsx-runtime"; +var dashboardRequest = null; +async function api(path, init) { + if (!dashboardRequest) throw new Error("Meme \u5DE5\u4F5C\u53F0\u9762\u677F\u672A\u6FC0\u6D3B"); + const response = await dashboardRequest(path, init); const body = await response.json(); - if (!response.ok) throw new Error(body?.detail || body?.message || `HTTP ${response.status}`); + if (!response.ok) throw new Error(String(body.detail ?? body.message ?? `HTTP ${response.status}`)); return body; } - -function showError(target, reason) { - target.setAttribute("role", "alert"); - target.textContent = reason instanceof Error ? reason.message : String(reason); +async function media(path, signal) { + if (!dashboardRequest) throw new Error("Meme \u5DE5\u4F5C\u53F0\u9762\u677F\u672A\u6FC0\u6D3B"); + const response = await dashboardRequest(path, { signal }); + if (!response.ok) throw new Error(`\u56FE\u7247\u8BFB\u53D6\u5931\u8D25\uFF1AHTTP ${response.status}`); + return response.blob(); +} +function MemeMain() { + const [categories, setCategories] = useState([]); + const [selectedTag, setSelectedTag] = useState(null); + const [images, setImages] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + useEffect(() => { + const controller = new AbortController(); + void api("/api/dashboard/meme/categories", { signal: controller.signal }).then((data) => { + if (controller.signal.aborted) return; + setCategories(data.categories || []); + if (data.categories?.length > 0) { + setSelectedTag(data.categories[0].tag); + } + setLoading(false); + }, (reason) => { + if (controller.signal.aborted) return; + setError(reason instanceof Error ? reason.message : "\u5206\u7C7B\u8BFB\u53D6\u5931\u8D25"); + setLoading(false); + }); + return () => controller.abort(); + }, []); + useEffect(() => { + if (!selectedTag) { + setImages([]); + return; + } + const controller = new AbortController(); + setImages([]); + setError(null); + void api(`/api/dashboard/meme/images/${selectedTag}`, { signal: controller.signal }).then((data) => { + if (!controller.signal.aborted) setImages(data.images || []); + }, (reason) => { + if (!controller.signal.aborted) { + setError(reason instanceof Error ? reason.message : "\u56FE\u7247\u8BFB\u53D6\u5931\u8D25"); + } + }); + return () => controller.abort(); + }, [selectedTag]); + return /* @__PURE__ */ jsxs("main", { className: "meme-dashboard", "aria-labelledby": "meme-dashboard-title", children: [ + /* @__PURE__ */ jsxs("nav", { className: "meme-dashboard__sidebar", "aria-label": "\u8868\u60C5\u5305\u5206\u7C7B", children: [ + /* @__PURE__ */ jsxs("div", { className: "meme-dashboard__sidebar-heading", children: [ + /* @__PURE__ */ jsx("h2", { children: "\u5206\u7C7B" }), + /* @__PURE__ */ jsx("span", { children: categories.length }) + ] }), + categories.map((c) => /* @__PURE__ */ jsxs("div", { className: "meme-category-row", children: [ + /* @__PURE__ */ jsxs( + "button", + { + type: "button", + className: `meme-category${selectedTag === c.tag ? " is-active" : ""}`, + onClick: () => setSelectedTag(c.tag), + "aria-current": selectedTag === c.tag ? "page" : void 0, + disabled: !c.enabled, + children: [ + /* @__PURE__ */ jsxs("span", { children: [ + /* @__PURE__ */ jsx("strong", { children: c.name || c.tag }), + /* @__PURE__ */ jsx("small", { children: c.desc || c.tag }) + ] }), + /* @__PURE__ */ jsx("b", { children: c.count }) + ] + } + ), + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "meme-delete", + onClick: () => { + if (confirm(`\u786E\u5B9A\u8981\u5220\u9664\u5206\u7C7B "${c.tag}" \u5417\uFF1F\u8FD9\u4F1A\u6C38\u4E45\u5220\u9664\u8BE5\u5206\u7C7B\u53CA\u5176\u6240\u6709\u56FE\u7247\uFF01`)) { + void api(`/api/dashboard/meme/categories/${c.tag}`, { method: "DELETE" }).then(() => { + setCategories((categories2) => { + const next = categories2.filter((cat) => cat.tag !== c.tag); + if (selectedTag === c.tag) setSelectedTag(next.length > 0 ? next[0].tag : null); + return next; + }); + }).catch((err) => alert("\u5220\u9664\u5931\u8D25\uFF1A" + err.message)); + } + }, + "aria-label": `\u5220\u9664\u5206\u7C7B ${c.tag}`, + title: "\u5220\u9664\u5206\u7C7B", + children: "\u2715" + } + ) + ] }, c.tag)), + !loading && categories.length === 0 && /* @__PURE__ */ jsx("p", { className: "meme-dashboard__nav-empty", children: "\u8FD8\u6CA1\u6709\u5206\u7C7B\u3002" }) + ] }), + /* @__PURE__ */ jsxs("section", { className: "meme-dashboard__gallery", children: [ + /* @__PURE__ */ jsxs("div", { className: "meme-dashboard__header", children: [ + /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx("p", { children: "\u8868\u60C5\u5305\u5E93" }), + /* @__PURE__ */ jsx("h1", { id: "meme-dashboard-title", children: selectedTag ? categories.find((c) => c.tag === selectedTag)?.name || selectedTag : "\u9009\u62E9\u4E00\u4E2A\u5206\u7C7B" }), + /* @__PURE__ */ jsx("div", { className: "meme-dashboard__description", children: selectedTag ? categories.find((c) => c.tag === selectedTag)?.desc : "" }) + ] }), + /* @__PURE__ */ jsxs("span", { className: "meme-dashboard__total", children: [ + images.length, + " \u5F20\u56FE\u7247" + ] }) + ] }), + /* @__PURE__ */ jsxs("div", { className: "meme-grid", "aria-live": "polite", children: [ + images.map((img) => /* @__PURE__ */ jsxs("figure", { className: "meme-item", children: [ + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "meme-item__delete", + onClick: (e) => { + e.stopPropagation(); + if (confirm(`\u786E\u5B9A\u8981\u5220\u9664\u56FE\u7247 "${img}" \u5417\uFF1F`)) { + void api(`/api/dashboard/meme/media/${selectedTag}/${img}`, { method: "DELETE" }).then(() => { + setImages((images2) => images2.filter((i) => i !== img)); + setCategories((categories2) => categories2.map((c) => c.tag === selectedTag ? { ...c, count: c.count - 1 } : c)); + }).catch((err) => alert("\u5220\u9664\u5931\u8D25\uFF1A" + err.message)); + } + }, + "aria-label": `\u5220\u9664\u56FE\u7247 ${img}`, + title: "\u5220\u9664\u56FE\u7247", + children: "\u2715" + } + ), + /* @__PURE__ */ jsx("div", { className: "meme-item__preview", children: /* @__PURE__ */ jsx(MemeImage, { tag: selectedTag, name: img }) }), + /* @__PURE__ */ jsx("figcaption", { title: img, children: img }) + ] }, img)), + loading && /* @__PURE__ */ jsx("div", { className: "meme-state", role: "status", children: "\u6B63\u5728\u8BFB\u53D6\u8868\u60C5\u5305\u5206\u7C7B\u2026" }), + !loading && !error && images.length === 0 && selectedTag && /* @__PURE__ */ jsxs("div", { className: "meme-state", children: [ + /* @__PURE__ */ jsx("strong", { children: "\u8FD9\u4E2A\u5206\u7C7B\u8FD8\u6CA1\u6709\u56FE\u7247" }), + /* @__PURE__ */ jsx("span", { children: "\u901A\u8FC7 Meme \u63D2\u4EF6\u5BFC\u5165\u56FE\u7247\u540E\u4F1A\u663E\u793A\u5728\u8FD9\u91CC\u3002" }) + ] }), + error && /* @__PURE__ */ jsxs("div", { className: "meme-state is-error", role: "alert", children: [ + /* @__PURE__ */ jsx("strong", { children: "\u65E0\u6CD5\u52A0\u8F7D\u8868\u60C5\u5305" }), + /* @__PURE__ */ jsx("span", { children: error }) + ] }) + ] }) + ] }) + ] }); +} +function MemeImage({ tag, name }) { + const [source, setSource] = useState(null); + useEffect(() => { + const controller = new AbortController(); + let objectUrl = null; + void media(`/api/dashboard/meme/media/${tag}/${name}`, controller.signal).then((blob) => { + objectUrl = URL.createObjectURL(blob); + setSource(objectUrl); + }, (reason) => { + if (!controller.signal.aborted) console.error("Meme \u56FE\u7247\u8BFB\u53D6\u5931\u8D25", reason); + }); + return () => { + controller.abort(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [name, tag]); + return /* @__PURE__ */ jsx("img", { src: source ?? void 0, alt: "", loading: "lazy" }); } - -function escapeHtml(value) { - return String(value).replace(/[&<>"']/g, (character) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[character]); +var panel = { + id: "meme", + label: "Meme \u8868\u60C5\u5305", + viewLabel: "\u8868\u60C5\u5305", + order: 50, + layout: "workbench", + rowKey: "id", + columns: [], + async getCount({ signal }) { + try { + const data = await api("/api/dashboard/meme/categories", { signal }); + let total = 0; + for (const cat of data.categories || []) { + total += cat.count; + } + return total; + } catch (error) { + if (signal.aborted) throw error; + return null; + } + }, + async fetchPage() { + return { items: [], total: 0 }; + }, + Main: MemeMain +}; +function activate(ctx) { + dashboardRequest = ctx.http.request; + const release = ctx.ui.inject("workbench.panels.v2", (mount) => mount.register(panel)); + return () => { + release(); + dashboardRequest = null; + }; } +export { + activate +}; From 1444fdc3a875c6bff603b97bf1867370116b2a0e Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 19:50:32 +0800 Subject: [PATCH 7/8] ci: pin Web UI Host SDK Core --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index abd030f..8d8c5b2 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 69a9616f6f48f19dc109a6b3eef8fc1000825829 + ref: dab4137c19fd1c5ef01ab69bacbc918d214f85c9 path: .akashic-core - uses: actions/checkout@v4 with: From 404494d21ecfa21eb003bac7078d9ce05dcfb5fb Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 22:21:57 +0800 Subject: [PATCH 8/8] refactor(web-ui): adopt workbench mount lifecycle --- .github/workflows/plugin-api-v3.yml | 2 +- dashboard_panel.tsx | 7 +- plugin.py | 2 +- web_module.css | 274 +--------------------------- web_module.js | 209 +-------------------- 5 files changed, 10 insertions(+), 484 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 8d8c5b2..ef65369 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: dab4137c19fd1c5ef01ab69bacbc918d214f85c9 + ref: d5e5177092d74de03588d8675f3504db36b7bacb path: .akashic-core - uses: actions/checkout@v4 with: diff --git a/dashboard_panel.tsx b/dashboard_panel.tsx index c30302a..a71a3b8 100644 --- a/dashboard_panel.tsx +++ b/dashboard_panel.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; import "./dashboard_panel.css"; import type { WebHostContextV1, WebUiDisposer } from "@akashic/web-ui-v1"; import type { WorkbenchPanelEntry } from "@akashic/workbench-ui-v2"; @@ -221,7 +222,11 @@ const panel = { return { items: [], total: 0 }; }, - Main: MemeMain, + renderMain(container: HTMLElement): WebUiDisposer { + const root = createRoot(container); + root.render(); + return () => root.unmount(); + }, } satisfies WorkbenchPanelEntry; export function activate(ctx: WebHostContextV1): WebUiDisposer { diff --git a/plugin.py b/plugin.py index c33eb9f..88717d3 100644 --- a/plugin.py +++ b/plugin.py @@ -50,7 +50,7 @@ def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None: web_requires = ("workbench.panels.v2",) web_provides = () web_contract_digests = { - "workbench.panels.v2": "17a005a381b362ae25a0499dbf95bf7a2c3ff0bb4e9b415e7357db458de6b5db", + "workbench.panels.v2": "fb6417c9bf532c1fdb344767d06065d5d3293da85deb64eff1e8088889a33bcb", } workspace_roots = ("memes",) diff --git a/web_module.css b/web_module.css index e864f04..43faaeb 100644 --- a/web_module.css +++ b/web_module.css @@ -1,273 +1 @@ -/* dashboard_panel.css */ -.meme-dashboard { - display: grid; - grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); - flex: 1; - height: 100%; - min-height: 0; - gap: 28px; - overflow: hidden; - padding: 24px; - color: var(--ak-color-text-primary); - background: var(--ak-color-bg-canvas); -} -.meme-dashboard__sidebar { - display: flex; - min-height: 0; - flex-direction: column; - gap: 6px; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; - padding-inline-end: 16px; - border-inline-end: 1px solid var(--ak-color-border-default); -} -.meme-dashboard__sidebar-heading, -.meme-dashboard__header { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 12px; -} -.meme-dashboard__sidebar-heading { - min-height: 40px; - margin-block-end: 6px; -} -.meme-dashboard__sidebar-heading h2, -.meme-dashboard__header h1 { - margin: 0; - color: var(--ak-color-text-primary); - letter-spacing: -0.02em; -} -.meme-dashboard__sidebar-heading h2 { - font-size: 15px; -} -.meme-dashboard__sidebar-heading span, -.meme-dashboard__total { - color: var(--ak-color-text-muted); - font-size: 12px; - font-variant-numeric: tabular-nums; -} -.meme-category-row { - display: grid; - grid-template-columns: minmax(0, 1fr) 40px; - gap: 4px; -} -.meme-category, -.meme-delete, -.meme-item__delete { - border: 0; - color: inherit; - background: transparent; - cursor: pointer; -} -.meme-category { - display: flex; - min-width: 0; - min-height: 60px; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - border-radius: 6px; - text-align: start; -} -.meme-category:hover { - background: var(--ak-color-bg-surface-high); -} -.meme-category.is-active { - color: var(--ak-color-action-primary); - background: var(--ak-color-action-soft); - box-shadow: inset 3px 0 0 var(--ak-color-action-primary); -} -.meme-category:disabled { - cursor: not-allowed; - opacity: 0.5; -} -.meme-category > span { - display: grid; - min-width: 0; - gap: 3px; -} -.meme-category strong, -.meme-category small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.meme-category strong { - font-size: 14px; - font-weight: 650; -} -.meme-category small { - color: var(--ak-color-text-muted); - font-size: 12px; -} -.meme-category b { - color: var(--ak-color-text-muted); - font-size: 12px; - font-variant-numeric: tabular-nums; -} -.meme-delete, -.meme-item__delete { - display: grid; - width: 40px; - height: 40px; - place-items: center; - border-radius: 6px; - color: var(--ak-color-text-muted); - font-size: 14px; -} -.meme-delete:hover, -.meme-item__delete:hover { - color: var(--ak-color-status-error); - background: color-mix(in oklch, var(--ak-color-status-error) 12%, transparent); -} -.meme-dashboard button:focus-visible, -.meme-dashboard summary:focus-visible { - outline: 2px solid var(--ak-color-action-primary); - outline-offset: 2px; -} -.meme-dashboard__gallery { - display: flex; - min-width: 0; - min-height: 0; - flex: 1; - flex-direction: column; - overflow: hidden; -} -.meme-dashboard__header { - margin-bottom: 20px; -} -.meme-dashboard__header p { - margin: 0 0 5px; - color: var(--ak-color-action-primary); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.09em; -} -.meme-dashboard__header h1 { - font-size: clamp(22px, 2vw, 28px); -} -.meme-dashboard__description { - margin-block-start: 6px; - color: var(--ak-color-text-muted); - font-size: 13px; -} -.meme-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - align-content: start; - gap: 16px; -} -.meme-item { - position: relative; - min-width: 0; - margin: 0; - padding: 8px; - border: 1px solid var(--ak-color-border-default); - border-radius: 7px; - background: var(--ak-color-bg-surface); -} -.meme-item__preview { - display: grid; - aspect-ratio: 1; - place-items: center; - overflow: hidden; - border-radius: 4px; - background: var(--ak-color-bg-surface-low); -} -.meme-item__preview img { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} -.meme-item__delete { - position: absolute; - z-index: 1; - top: 12px; - right: 12px; - border: 1px solid var(--ak-color-border-default); - background: var(--ak-color-bg-surface); -} -.meme-item figcaption { - overflow: hidden; - margin-block-start: 8px; - color: var(--ak-color-text-muted); - font-family: var(--mono); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} -.meme-state { - display: grid; - grid-column: 1 / -1; - min-height: 180px; - place-content: center; - gap: 6px; - padding: 24px; - border: 1px dashed var(--ak-color-border-strong); - border-radius: 7px; - color: var(--ak-color-text-muted); - text-align: center; -} -.meme-state strong { - color: var(--ak-color-text-primary); -} -.meme-state.is-error { - border-color: color-mix(in oklch, var(--ak-color-status-error) 40%, var(--ak-color-border-default)); -} -.meme-state.is-error strong { - color: var(--ak-color-status-error); -} -.meme-dashboard__nav-empty { - margin: 8px 12px; - color: var(--ak-color-text-muted); - font-size: 13px; -} -@media (max-width: 720px) { - .meme-dashboard { - display: block; - overflow-y: auto; - padding: 16px; - } - .meme-dashboard__sidebar { - display: grid; - grid-auto-columns: minmax(180px, 72vw); - grid-auto-flow: column; - gap: 8px; - overflow-x: auto; - overflow-y: hidden; - margin-block-end: 24px; - padding: 0 0 12px; - border-inline-end: 0; - border-block-end: 1px solid var(--ak-color-border-default); - } - .meme-dashboard__sidebar-heading, - .meme-dashboard__nav-empty { - grid-column: 1; - } - .meme-category-row { - grid-row: 2; - } - .meme-dashboard__gallery { - overflow: visible; - } - .meme-dashboard__header { - align-items: flex-start; - flex-wrap: wrap; - } - .meme-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - } -} -@media (prefers-reduced-motion: reduce) { - .meme-dashboard *, - .meme-dashboard *::before, - .meme-dashboard *::after { - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - } -} +.meme-dashboard{display:grid;grid-template-columns:minmax(220px,260px) minmax(0,1fr);flex:1;height:100%;min-height:0;gap:28px;overflow:hidden;padding:24px;color:var(--ak-color-text-primary);background:var(--ak-color-bg-canvas)}.meme-dashboard__sidebar{display:flex;min-height:0;flex-direction:column;gap:6px;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;padding-inline-end:16px;border-inline-end:1px solid var(--ak-color-border-default)}.meme-dashboard__sidebar-heading,.meme-dashboard__header{display:flex;align-items:flex-end;justify-content:space-between;gap:12px}.meme-dashboard__sidebar-heading{min-height:40px;margin-block-end:6px}.meme-dashboard__sidebar-heading h2,.meme-dashboard__header h1{margin:0;color:var(--ak-color-text-primary);letter-spacing:-.02em}.meme-dashboard__sidebar-heading h2{font-size:15px}.meme-dashboard__sidebar-heading span,.meme-dashboard__total{color:var(--ak-color-text-muted);font-size:12px;font-variant-numeric:tabular-nums}.meme-category-row{display:grid;grid-template-columns:minmax(0,1fr) 40px;gap:4px}.meme-category,.meme-delete,.meme-item__delete{border:0;color:inherit;background:transparent;cursor:pointer}.meme-category{display:flex;min-width:0;min-height:60px;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px;border-radius:6px;text-align:start}.meme-category:hover{background:var(--ak-color-bg-surface-high)}.meme-category.is-active{color:var(--ak-color-action-primary);background:var(--ak-color-action-soft);box-shadow:inset 3px 0 0 var(--ak-color-action-primary)}.meme-category:disabled{cursor:not-allowed;opacity:.5}.meme-category>span{display:grid;min-width:0;gap:3px}.meme-category strong,.meme-category small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.meme-category strong{font-size:14px;font-weight:650}.meme-category small{color:var(--ak-color-text-muted);font-size:12px}.meme-category b{color:var(--ak-color-text-muted);font-size:12px;font-variant-numeric:tabular-nums}.meme-delete,.meme-item__delete{display:grid;width:40px;height:40px;place-items:center;border-radius:6px;color:var(--ak-color-text-muted);font-size:14px}.meme-delete:hover,.meme-item__delete:hover{color:var(--ak-color-status-error);background:color-mix(in oklch,var(--ak-color-status-error) 12%,transparent)}.meme-dashboard button:focus-visible,.meme-dashboard summary:focus-visible{outline:2px solid var(--ak-color-action-primary);outline-offset:2px}.meme-dashboard__gallery{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column;overflow:hidden}.meme-dashboard__header{margin-bottom:20px}.meme-dashboard__header p{margin:0 0 5px;color:var(--ak-color-action-primary);font-size:11px;font-weight:700;letter-spacing:.09em}.meme-dashboard__header h1{font-size:clamp(22px,2vw,28px)}.meme-dashboard__description{margin-block-start:6px;color:var(--ak-color-text-muted);font-size:13px}.meme-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));align-content:start;gap:16px}.meme-item{position:relative;min-width:0;margin:0;padding:8px;border:1px solid var(--ak-color-border-default);border-radius:7px;background:var(--ak-color-bg-surface)}.meme-item__preview{display:grid;aspect-ratio:1;place-items:center;overflow:hidden;border-radius:4px;background:var(--ak-color-bg-surface-low)}.meme-item__preview img{display:block;width:100%;height:100%;object-fit:contain}.meme-item__delete{position:absolute;z-index:1;top:12px;right:12px;border:1px solid var(--ak-color-border-default);background:var(--ak-color-bg-surface)}.meme-item figcaption{overflow:hidden;margin-block-start:8px;color:var(--ak-color-text-muted);font-family:var(--mono);font-size:11px;text-overflow:ellipsis;white-space:nowrap}.meme-state{display:grid;grid-column:1 / -1;min-height:180px;place-content:center;gap:6px;padding:24px;border:1px dashed var(--ak-color-border-strong);border-radius:7px;color:var(--ak-color-text-muted);text-align:center}.meme-state strong{color:var(--ak-color-text-primary)}.meme-state.is-error{border-color:color-mix(in oklch,var(--ak-color-status-error) 40%,var(--ak-color-border-default))}.meme-state.is-error strong{color:var(--ak-color-status-error)}.meme-dashboard__nav-empty{margin:8px 12px;color:var(--ak-color-text-muted);font-size:13px}@media(max-width:720px){.meme-dashboard{display:block;overflow-y:auto;padding:16px}.meme-dashboard__sidebar{display:grid;grid-auto-columns:minmax(180px,72vw);grid-auto-flow:column;gap:8px;overflow-x:auto;overflow-y:hidden;margin-block-end:24px;padding:0 0 12px;border-inline-end:0;border-block-end:1px solid var(--ak-color-border-default)}.meme-dashboard__sidebar-heading,.meme-dashboard__nav-empty{grid-column:1}.meme-category-row{grid-row:2}.meme-dashboard__gallery{overflow:visible}.meme-dashboard__header{align-items:flex-start;flex-wrap:wrap}.meme-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}}@media(prefers-reduced-motion:reduce){.meme-dashboard *,.meme-dashboard *:before,.meme-dashboard *:after{scroll-behavior:auto!important;transition-duration:.01ms!important}} diff --git a/web_module.js b/web_module.js index fed1865..afde58c 100644 --- a/web_module.js +++ b/web_module.js @@ -1,208 +1 @@ -// dashboard_panel.tsx -import { useEffect, useState } from "react"; -import { jsx, jsxs } from "react/jsx-runtime"; -var dashboardRequest = null; -async function api(path, init) { - if (!dashboardRequest) throw new Error("Meme \u5DE5\u4F5C\u53F0\u9762\u677F\u672A\u6FC0\u6D3B"); - const response = await dashboardRequest(path, init); - const body = await response.json(); - if (!response.ok) throw new Error(String(body.detail ?? body.message ?? `HTTP ${response.status}`)); - return body; -} -async function media(path, signal) { - if (!dashboardRequest) throw new Error("Meme \u5DE5\u4F5C\u53F0\u9762\u677F\u672A\u6FC0\u6D3B"); - const response = await dashboardRequest(path, { signal }); - if (!response.ok) throw new Error(`\u56FE\u7247\u8BFB\u53D6\u5931\u8D25\uFF1AHTTP ${response.status}`); - return response.blob(); -} -function MemeMain() { - const [categories, setCategories] = useState([]); - const [selectedTag, setSelectedTag] = useState(null); - const [images, setImages] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - useEffect(() => { - const controller = new AbortController(); - void api("/api/dashboard/meme/categories", { signal: controller.signal }).then((data) => { - if (controller.signal.aborted) return; - setCategories(data.categories || []); - if (data.categories?.length > 0) { - setSelectedTag(data.categories[0].tag); - } - setLoading(false); - }, (reason) => { - if (controller.signal.aborted) return; - setError(reason instanceof Error ? reason.message : "\u5206\u7C7B\u8BFB\u53D6\u5931\u8D25"); - setLoading(false); - }); - return () => controller.abort(); - }, []); - useEffect(() => { - if (!selectedTag) { - setImages([]); - return; - } - const controller = new AbortController(); - setImages([]); - setError(null); - void api(`/api/dashboard/meme/images/${selectedTag}`, { signal: controller.signal }).then((data) => { - if (!controller.signal.aborted) setImages(data.images || []); - }, (reason) => { - if (!controller.signal.aborted) { - setError(reason instanceof Error ? reason.message : "\u56FE\u7247\u8BFB\u53D6\u5931\u8D25"); - } - }); - return () => controller.abort(); - }, [selectedTag]); - return /* @__PURE__ */ jsxs("main", { className: "meme-dashboard", "aria-labelledby": "meme-dashboard-title", children: [ - /* @__PURE__ */ jsxs("nav", { className: "meme-dashboard__sidebar", "aria-label": "\u8868\u60C5\u5305\u5206\u7C7B", children: [ - /* @__PURE__ */ jsxs("div", { className: "meme-dashboard__sidebar-heading", children: [ - /* @__PURE__ */ jsx("h2", { children: "\u5206\u7C7B" }), - /* @__PURE__ */ jsx("span", { children: categories.length }) - ] }), - categories.map((c) => /* @__PURE__ */ jsxs("div", { className: "meme-category-row", children: [ - /* @__PURE__ */ jsxs( - "button", - { - type: "button", - className: `meme-category${selectedTag === c.tag ? " is-active" : ""}`, - onClick: () => setSelectedTag(c.tag), - "aria-current": selectedTag === c.tag ? "page" : void 0, - disabled: !c.enabled, - children: [ - /* @__PURE__ */ jsxs("span", { children: [ - /* @__PURE__ */ jsx("strong", { children: c.name || c.tag }), - /* @__PURE__ */ jsx("small", { children: c.desc || c.tag }) - ] }), - /* @__PURE__ */ jsx("b", { children: c.count }) - ] - } - ), - /* @__PURE__ */ jsx( - "button", - { - type: "button", - className: "meme-delete", - onClick: () => { - if (confirm(`\u786E\u5B9A\u8981\u5220\u9664\u5206\u7C7B "${c.tag}" \u5417\uFF1F\u8FD9\u4F1A\u6C38\u4E45\u5220\u9664\u8BE5\u5206\u7C7B\u53CA\u5176\u6240\u6709\u56FE\u7247\uFF01`)) { - void api(`/api/dashboard/meme/categories/${c.tag}`, { method: "DELETE" }).then(() => { - setCategories((categories2) => { - const next = categories2.filter((cat) => cat.tag !== c.tag); - if (selectedTag === c.tag) setSelectedTag(next.length > 0 ? next[0].tag : null); - return next; - }); - }).catch((err) => alert("\u5220\u9664\u5931\u8D25\uFF1A" + err.message)); - } - }, - "aria-label": `\u5220\u9664\u5206\u7C7B ${c.tag}`, - title: "\u5220\u9664\u5206\u7C7B", - children: "\u2715" - } - ) - ] }, c.tag)), - !loading && categories.length === 0 && /* @__PURE__ */ jsx("p", { className: "meme-dashboard__nav-empty", children: "\u8FD8\u6CA1\u6709\u5206\u7C7B\u3002" }) - ] }), - /* @__PURE__ */ jsxs("section", { className: "meme-dashboard__gallery", children: [ - /* @__PURE__ */ jsxs("div", { className: "meme-dashboard__header", children: [ - /* @__PURE__ */ jsxs("div", { children: [ - /* @__PURE__ */ jsx("p", { children: "\u8868\u60C5\u5305\u5E93" }), - /* @__PURE__ */ jsx("h1", { id: "meme-dashboard-title", children: selectedTag ? categories.find((c) => c.tag === selectedTag)?.name || selectedTag : "\u9009\u62E9\u4E00\u4E2A\u5206\u7C7B" }), - /* @__PURE__ */ jsx("div", { className: "meme-dashboard__description", children: selectedTag ? categories.find((c) => c.tag === selectedTag)?.desc : "" }) - ] }), - /* @__PURE__ */ jsxs("span", { className: "meme-dashboard__total", children: [ - images.length, - " \u5F20\u56FE\u7247" - ] }) - ] }), - /* @__PURE__ */ jsxs("div", { className: "meme-grid", "aria-live": "polite", children: [ - images.map((img) => /* @__PURE__ */ jsxs("figure", { className: "meme-item", children: [ - /* @__PURE__ */ jsx( - "button", - { - type: "button", - className: "meme-item__delete", - onClick: (e) => { - e.stopPropagation(); - if (confirm(`\u786E\u5B9A\u8981\u5220\u9664\u56FE\u7247 "${img}" \u5417\uFF1F`)) { - void api(`/api/dashboard/meme/media/${selectedTag}/${img}`, { method: "DELETE" }).then(() => { - setImages((images2) => images2.filter((i) => i !== img)); - setCategories((categories2) => categories2.map((c) => c.tag === selectedTag ? { ...c, count: c.count - 1 } : c)); - }).catch((err) => alert("\u5220\u9664\u5931\u8D25\uFF1A" + err.message)); - } - }, - "aria-label": `\u5220\u9664\u56FE\u7247 ${img}`, - title: "\u5220\u9664\u56FE\u7247", - children: "\u2715" - } - ), - /* @__PURE__ */ jsx("div", { className: "meme-item__preview", children: /* @__PURE__ */ jsx(MemeImage, { tag: selectedTag, name: img }) }), - /* @__PURE__ */ jsx("figcaption", { title: img, children: img }) - ] }, img)), - loading && /* @__PURE__ */ jsx("div", { className: "meme-state", role: "status", children: "\u6B63\u5728\u8BFB\u53D6\u8868\u60C5\u5305\u5206\u7C7B\u2026" }), - !loading && !error && images.length === 0 && selectedTag && /* @__PURE__ */ jsxs("div", { className: "meme-state", children: [ - /* @__PURE__ */ jsx("strong", { children: "\u8FD9\u4E2A\u5206\u7C7B\u8FD8\u6CA1\u6709\u56FE\u7247" }), - /* @__PURE__ */ jsx("span", { children: "\u901A\u8FC7 Meme \u63D2\u4EF6\u5BFC\u5165\u56FE\u7247\u540E\u4F1A\u663E\u793A\u5728\u8FD9\u91CC\u3002" }) - ] }), - error && /* @__PURE__ */ jsxs("div", { className: "meme-state is-error", role: "alert", children: [ - /* @__PURE__ */ jsx("strong", { children: "\u65E0\u6CD5\u52A0\u8F7D\u8868\u60C5\u5305" }), - /* @__PURE__ */ jsx("span", { children: error }) - ] }) - ] }) - ] }) - ] }); -} -function MemeImage({ tag, name }) { - const [source, setSource] = useState(null); - useEffect(() => { - const controller = new AbortController(); - let objectUrl = null; - void media(`/api/dashboard/meme/media/${tag}/${name}`, controller.signal).then((blob) => { - objectUrl = URL.createObjectURL(blob); - setSource(objectUrl); - }, (reason) => { - if (!controller.signal.aborted) console.error("Meme \u56FE\u7247\u8BFB\u53D6\u5931\u8D25", reason); - }); - return () => { - controller.abort(); - if (objectUrl) URL.revokeObjectURL(objectUrl); - }; - }, [name, tag]); - return /* @__PURE__ */ jsx("img", { src: source ?? void 0, alt: "", loading: "lazy" }); -} -var panel = { - id: "meme", - label: "Meme \u8868\u60C5\u5305", - viewLabel: "\u8868\u60C5\u5305", - order: 50, - layout: "workbench", - rowKey: "id", - columns: [], - async getCount({ signal }) { - try { - const data = await api("/api/dashboard/meme/categories", { signal }); - let total = 0; - for (const cat of data.categories || []) { - total += cat.count; - } - return total; - } catch (error) { - if (signal.aborted) throw error; - return null; - } - }, - async fetchPage() { - return { items: [], total: 0 }; - }, - Main: MemeMain -}; -function activate(ctx) { - dashboardRequest = ctx.http.request; - const release = ctx.ui.inject("workbench.panels.v2", (mount) => mount.register(panel)); - return () => { - release(); - dashboardRequest = null; - }; -} -export { - activate -}; +import{useEffect as x,useState as p}from"react";import{createRoot as y}from"react-dom/client";import{jsx as r,jsxs as n}from"react/jsx-runtime";var b=null;async function u(o,t){if(!b)throw new Error("Meme 工作台面板未激活");let a=await b(o,t),s=await a.json();if(!a.ok)throw new Error(String(s.detail??s.message??`HTTP ${a.status}`));return s}async function _(o,t){if(!b)throw new Error("Meme 工作台面板未激活");let a=await b(o,{signal:t});if(!a.ok)throw new Error(`图片读取失败:HTTP ${a.status}`);return a.blob()}function w(){let[o,t]=p([]),[a,s]=p(null),[c,m]=p([]),[g,h]=p(null),[f,v]=p(!0);return x(()=>{let e=new AbortController;return u("/api/dashboard/meme/categories",{signal:e.signal}).then(i=>{e.signal.aborted||(t(i.categories||[]),i.categories?.length>0&&s(i.categories[0].tag),v(!1))},i=>{e.signal.aborted||(h(i instanceof Error?i.message:"分类读取失败"),v(!1))}),()=>e.abort()},[]),x(()=>{if(!a){m([]);return}let e=new AbortController;return m([]),h(null),u(`/api/dashboard/meme/images/${a}`,{signal:e.signal}).then(i=>{e.signal.aborted||m(i.images||[])},i=>{e.signal.aborted||h(i instanceof Error?i.message:"图片读取失败")}),()=>e.abort()},[a]),n("main",{className:"meme-dashboard","aria-labelledby":"meme-dashboard-title",children:[n("nav",{className:"meme-dashboard__sidebar","aria-label":"表情包分类",children:[n("div",{className:"meme-dashboard__sidebar-heading",children:[r("h2",{children:"分类"}),r("span",{children:o.length})]}),o.map(e=>n("div",{className:"meme-category-row",children:[n("button",{type:"button",className:`meme-category${a===e.tag?" is-active":""}`,onClick:()=>s(e.tag),"aria-current":a===e.tag?"page":void 0,disabled:!e.enabled,children:[n("span",{children:[r("strong",{children:e.name||e.tag}),r("small",{children:e.desc||e.tag})]}),r("b",{children:e.count})]}),r("button",{type:"button",className:"meme-delete",onClick:()=>{confirm(`确定要删除分类 "${e.tag}" 吗?这会永久删除该分类及其所有图片!`)&&u(`/api/dashboard/meme/categories/${e.tag}`,{method:"DELETE"}).then(()=>{t(i=>{let l=i.filter(d=>d.tag!==e.tag);return a===e.tag&&s(l.length>0?l[0].tag:null),l})}).catch(i=>alert("删除失败:"+i.message))},"aria-label":`删除分类 ${e.tag}`,title:"删除分类",children:"✕"})]},e.tag)),!f&&o.length===0&&r("p",{className:"meme-dashboard__nav-empty",children:"还没有分类。"})]}),n("section",{className:"meme-dashboard__gallery",children:[n("div",{className:"meme-dashboard__header",children:[n("div",{children:[r("p",{children:"表情包库"}),r("h1",{id:"meme-dashboard-title",children:a?o.find(e=>e.tag===a)?.name||a:"选择一个分类"}),r("div",{className:"meme-dashboard__description",children:a?o.find(e=>e.tag===a)?.desc:""})]}),n("span",{className:"meme-dashboard__total",children:[c.length," 张图片"]})]}),n("div",{className:"meme-grid","aria-live":"polite",children:[c.map(e=>n("figure",{className:"meme-item",children:[r("button",{type:"button",className:"meme-item__delete",onClick:i=>{i.stopPropagation(),confirm(`确定要删除图片 "${e}" 吗?`)&&u(`/api/dashboard/meme/media/${a}/${e}`,{method:"DELETE"}).then(()=>{m(l=>l.filter(d=>d!==e)),t(l=>l.map(d=>d.tag===a?{...d,count:d.count-1}:d))}).catch(l=>alert("删除失败:"+l.message))},"aria-label":`删除图片 ${e}`,title:"删除图片",children:"✕"}),r("div",{className:"meme-item__preview",children:r(k,{tag:a,name:e})}),r("figcaption",{title:e,children:e})]},e)),f&&r("div",{className:"meme-state",role:"status",children:"正在读取表情包分类…"}),!f&&!g&&c.length===0&&a&&n("div",{className:"meme-state",children:[r("strong",{children:"这个分类还没有图片"}),r("span",{children:"通过 Meme 插件导入图片后会显示在这里。"})]}),g&&n("div",{className:"meme-state is-error",role:"alert",children:[r("strong",{children:"无法加载表情包"}),r("span",{children:g})]})]})]})]})}function k({tag:o,name:t}){let[a,s]=p(null);return x(()=>{let c=new AbortController,m=null;return _(`/api/dashboard/meme/media/${o}/${t}`,c.signal).then(g=>{m=URL.createObjectURL(g),s(m)},g=>{c.signal.aborted||console.error("Meme 图片读取失败",g)}),()=>{c.abort(),m&&URL.revokeObjectURL(m)}},[t,o]),r("img",{src:a??void 0,alt:"",loading:"lazy"})}var E={id:"meme",label:"Meme 表情包",viewLabel:"表情包",order:50,layout:"workbench",rowKey:"id",columns:[],async getCount({signal:o}){try{let t=await u("/api/dashboard/meme/categories",{signal:o}),a=0;for(let s of t.categories||[])a+=s.count;return a}catch(t){if(o.aborted)throw t;return null}},async fetchPage(){return{items:[],total:0}},renderMain(o){let t=y(o);return t.render(r(w,{})),()=>t.unmount()}};function M(o){b=o.http.request;let t=o.ui.inject("workbench.panels.v2",a=>a.register(E));return()=>{t(),b=null}}export{M as activate};