diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml
index 392cdec..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: 3005f838bcd96e2cbc58616aede46e4f39df4523
+ ref: d5e5177092d74de03588d8675f3504db36b7bacb
path: .akashic-core
- uses: actions/checkout@v4
with:
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.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.css b/dashboard_panel.css
index 4e2fe95..8098bc0 100644
--- a/dashboard_panel.css
+++ b/dashboard_panel.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);
diff --git a/dashboard_panel.tsx b/dashboard_panel.tsx
index f31d2d2..a71a3b8 100644
--- a/dashboard_panel.tsx
+++ b/dashboard_panel.tsx
@@ -1,13 +1,24 @@
-///
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;
- }
+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";
+
+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 {
@@ -27,26 +38,38 @@ function MemeMain() {
const [loading, setLoading] = useState(true);
useEffect(() => {
- void api("/api/dashboard/meme/categories").then((data: any) => {
+ 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) {
- void api(`/api/dashboard/meme/images/${selectedTag}`).then((data: any) => {
- setImages(data.images || []);
- }, (reason: unknown) => setError(reason instanceof Error ? reason.message : "图片读取失败"));
- } else {
+ 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 (
@@ -126,9 +149,7 @@ function MemeMain() {
>
✕
-
-

-
+
{img}
@@ -153,21 +174,46 @@ function MemeMain() {
);
}
-window.AkashicDashboard.registerPlugin({
+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(): Promise {
+ async getCount({ signal }: { signal: AbortSignal }): Promise {
try {
- const data = await api("/api/dashboard/meme/categories");
+ const data = await api("/api/dashboard/meme/categories", { signal });
let total = 0;
for (const cat of data.categories || []) {
total += cat.count;
}
return total;
- } catch {
+ } catch (error) {
+ if (signal.aborted) throw error;
return null;
}
},
@@ -176,5 +222,18 @@ window.AkashicDashboard.registerPlugin({
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 {
+ 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 fb579f1..88717d3 100644
--- a/plugin.py
+++ b/plugin.py
@@ -42,10 +42,16 @@ 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.v2",)
+web_provides = ()
+web_contract_digests = {
+ "workbench.panels.v2": "fb6417c9bf532c1fdb344767d06065d5d3293da85deb64eff1e8088889a33bcb",
+}
workspace_roots = ("memes",)
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 76fd844..2346682 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
@@ -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"
@@ -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/web_module.css b/web_module.css
new file mode 100644
index 0000000..43faaeb
--- /dev/null
+++ b/web_module.css
@@ -0,0 +1 @@
+.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
new file mode 100644
index 0000000..afde58c
--- /dev/null
+++ b/web_module.js
@@ -0,0 +1 @@
+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};