新增插件指引网页

This commit is contained in:
2025-11-12 22:58:36 +08:00
parent 4a3beb2153
commit 7332141a92
34 changed files with 2373 additions and 8984 deletions

View File

@@ -5,6 +5,9 @@ from PWF.Convention.Runtime.Architecture import Architecture
from PWF.Convention.Runtime.GlobalConfig import ProjectConfig
from PWF.Convention.Runtime.Web import ToolURL
from PWF.Convention.Runtime.String import LimitStringLength
from fastapi.responses import HTMLResponse
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Sequence, TypedDict, override, Union
import httpx
import re
@@ -13,6 +16,613 @@ MAIN_WEBHOOK_URL = logger.FindItem("main_webhook_url", "")
logger.SaveProperties()
class GuideEntry(TypedDict, total=False):
"""单条图鉴信息。"""
title: str
identifier: str
description: str
category: str
metadata: Dict[str, str]
icon: str
badge: str
links: Sequence[Dict[str, str]]
tags: Sequence[str]
details: Sequence[Union[str, Dict[str, Any]]]
group: str
@dataclass(frozen=True)
class GuideSection:
"""图鉴章节。"""
title: str
entries: Sequence[GuideEntry] = field(default_factory=tuple)
description: str = ""
layout: str = "grid"
section_id: str | None = None
@dataclass(frozen=True)
class GuidePage:
"""完整图鉴页面。"""
title: str
sections: Sequence[GuideSection] = field(default_factory=tuple)
subtitle: str = ""
metadata: Dict[str, str] = field(default_factory=dict)
related_links: Dict[str, Sequence[Dict[str, str]]] = field(default_factory=dict)
def render_markdown_page(page: GuidePage) -> str:
"""保留 Markdown 渲染(备用)。"""
def _render_section(section: GuideSection) -> str:
lines: List[str] = [f"## {section.title}"]
if section.description:
lines.append(section.description)
if not section.entries:
lines.append("> 暂无内容。")
return "\n".join(lines)
for entry in section.entries:
title = entry.get("title", "未命名")
identifier = entry.get("identifier")
desc = entry.get("description", "")
category = entry.get("category")
metadata = entry.get("metadata", {})
bullet = f"- **{title}**"
if identifier:
bullet += f"`{identifier}`"
if category:
bullet += f"{category}"
lines.append(bullet)
if desc:
lines.append(f" - {desc}")
for meta_key, meta_value in metadata.items():
lines.append(f" - {meta_key}{meta_value}")
return "\n".join(lines)
lines: List[str] = [f"# {page.title}"]
if page.subtitle:
lines.append(page.subtitle)
if page.metadata:
lines.append("")
for key, value in page.metadata.items():
lines.append(f"- {key}{value}")
for section in page.sections:
lines.append("")
lines.append(_render_section(section))
return "\n".join(lines)
def render_html_page(page: GuidePage) -> str:
"""渲染 Apple Store 风格的 HTML 页面。"""
def escape(text: Optional[str]) -> str:
if not text:
return ""
return (
text.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def render_metadata(metadata: Dict[str, str]) -> str:
if not metadata:
return ""
cards = []
for key, value in metadata.items():
cards.append(
f"""
<div class="meta-card">
<div class="meta-key">{escape(key)}</div>
<div class="meta-value">{escape(value)}</div>
</div>
"""
)
return f'<section class="meta-grid">{"".join(cards)}</section>'
def render_links(links: Optional[Sequence[Dict[str, str]]]) -> str:
if not links:
return ""
items = []
for link in links:
href = escape(link.get("href", "#"))
label = escape(link.get("label", "前往"))
items.append(f'<a class="entry-link" href="{href}" target="_blank">{label}</a>')
return "".join(items)
def render_tags(tags: Optional[Sequence[str]]) -> str:
if not tags:
return ""
chips = "".join(f'<span class="entry-tag">{escape(tag)}</span>' for tag in tags)
return f'<div class="entry-tags-extra">{chips}</div>'
def render_details(details: Optional[Sequence[Union[str, Dict[str, Any]]]]) -> str:
if not details:
return ""
blocks: List[str] = []
for detail in details:
if isinstance(detail, str):
blocks.append(f'<p class="entry-detail-paragraph">{escape(detail)}</p>')
elif isinstance(detail, dict):
kind = detail.get("type")
if kind == "list":
items = "".join(
f'<li>{escape(str(item))}</li>' for item in detail.get("items", [])
)
blocks.append(f'<ul class="entry-detail-list">{items}</ul>')
elif kind == "steps":
items = "".join(
f'<li><span class="step-index">{idx+1}</span><span>{escape(str(item))}</span></li>'
for idx, item in enumerate(detail.get("items", []))
)
blocks.append(f'<ol class="entry-detail-steps">{items}</ol>')
elif kind == "table":
rows = []
for row in detail.get("rows", []):
cols = "".join(f"<td>{escape(str(col))}</td>" for col in row)
rows.append(f"<tr>{cols}</tr>")
head = ""
headers = detail.get("headers")
if headers:
head = "".join(f"<th>{escape(str(col))}</th>" for col in headers)
head = f"<thead><tr>{head}</tr></thead>"
blocks.append(
f'<table class="entry-detail-table">{head}<tbody>{"".join(rows)}</tbody></table>'
)
if not blocks:
return ""
return f'<div class="entry-details">{"".join(blocks)}</div>'
def render_entry(entry: GuideEntry) -> str:
icon = escape(entry.get("icon"))
badge = escape(entry.get("badge"))
title = escape(entry.get("title"))
identifier = escape(entry.get("identifier"))
description = escape(entry.get("description"))
category = escape(entry.get("category"))
metadata_items = []
for meta_key, meta_value in entry.get("metadata", {}).items():
metadata_items.append(
f'<li><span>{escape(meta_key)}</span><span>{escape(str(meta_value))}</span></li>'
)
metadata_html = ""
if metadata_items:
metadata_html = f'<ul class="entry-meta">{"".join(metadata_items)}</ul>'
identifier_html = f'<code class="entry-id">{identifier}</code>' if identifier else ""
category_html = f'<span class="entry-category">{category}</span>' if category else ""
badge_html = f'<span class="entry-badge">{badge}</span>' if badge else ""
icon_html = f'<div class="entry-icon">{icon}</div>' if icon else ""
links_html = render_links(entry.get("links"))
tags_html = render_tags(entry.get("tags"))
details_html = render_details(entry.get("details"))
group = escape(entry.get("group"))
group_attr = f' data-group="{group}"' if group else ""
return f"""
<article class="entry-card"{group_attr}>
{icon_html}
<div class="entry-content">
<header>
<h3>{title}{badge_html}</h3>
<div class="entry-tags">{identifier_html}{category_html}</div>
</header>
<p class="entry-desc">{description}</p>
{metadata_html}
{tags_html}
{details_html}
{links_html}
</div>
</article>
"""
def render_section(section: GuideSection) -> str:
layout_class = "entries-grid" if section.layout == "grid" else "entries-list"
section_attr = f' id="{escape(section.section_id)}"' if section.section_id else ""
cards = "".join(render_entry(entry) for entry in section.entries)
description_html = (
f'<p class="section-desc">{escape(section.description)}</p>'
if section.description
else ""
)
if not cards:
cards = '<div class="empty-placeholder">暂无内容</div>'
return f"""
<section class="guide-section"{section_attr}>
<div class="section-header">
<h2>{escape(section.title)}</h2>
{description_html}
</div>
<div class="{layout_class}">
{cards}
</div>
</section>
"""
def render_related(related: Dict[str, Sequence[Dict[str, str]]]) -> str:
if not related:
return ""
blocks: List[str] = []
for label, links in related.items():
if not links:
continue
items = "".join(
f'<a class="related-link" href="{escape(link.get("href", "#"))}">{escape(link.get("label", ""))}</a>'
for link in links
)
blocks.append(
f"""
<div class="related-block">
<div class="related-label">{escape(label)}</div>
<div class="related-items">{items}</div>
</div>
"""
)
if not blocks:
return ""
return f'<section class="related-section">{"".join(blocks)}</section>'
sections_html = "".join(render_section(section) for section in page.sections)
metadata_html = render_metadata(page.metadata)
related_html = render_related(page.related_links)
subtitle_html = f'<p class="hero-subtitle">{escape(page.subtitle)}</p>' if page.subtitle else ""
return f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{escape(page.title)}</title>
<style>
:root {{
color-scheme: dark;
--bg-primary: radial-gradient(120% 120% at 10% 10%, #222 0%, #050505 100%);
--bg-card: rgba(255, 255, 255, 0.06);
--bg-card-hover: rgba(255, 255, 255, 0.12);
--border-soft: rgba(255, 255, 255, 0.1);
--text-main: #f5f5f7;
--text-sub: rgba(245, 245, 247, 0.64);
--accent: linear-gradient(135deg, #4b7bec, #34e7e4);
--accent-strong: linear-gradient(135deg, #ff9f1a, #ff3f34);
}}
* {{
box-sizing: border-box;
}}
body {{
margin: 0;
font-family: "SF Pro Display", "SF Pro SC", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg-primary);
color: var(--text-main);
min-height: 100vh;
padding: 0 0 64px;
}}
a {{
color: #9fc9ff;
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
header.hero {{
padding: 80px 24px 40px;
text-align: center;
position: relative;
}}
header.hero::after {{
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% -20%, rgba(255, 255, 255, 0.18), transparent 55%);
pointer-events: none;
z-index: -1;
}}
.hero-title {{
font-size: clamp(32px, 5vw, 48px);
font-weight: 700;
margin: 0;
background: var(--accent);
-webkit-background-clip: text;
color: transparent;
letter-spacing: 0.8px;
}}
.hero-subtitle {{
margin: 16px auto 0;
max-width: 640px;
font-size: 18px;
color: var(--text-sub);
line-height: 1.6;
}}
main {{
width: min(1100px, 92vw);
margin: 0 auto;
}}
.meta-grid {{
display: grid;
gap: 20px;
margin: 0 auto 48px;
padding: 0 8px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}}
.related-section {{
margin: 0 auto 48px;
padding: 0 8px;
display: grid;
gap: 18px;
}}
.related-block {{
display: grid;
gap: 8px;
}}
.related-label {{
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-sub);
}}
.related-items {{
display: flex;
gap: 10px;
flex-wrap: wrap;
}}
.related-link {{
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 13px;
letter-spacing: 0.4px;
transition: background 0.2s ease;
}}
.related-link:hover {{
background: rgba(255, 255, 255, 0.16);
text-decoration: none;
}}
.meta-card {{
border-radius: 18px;
padding: 22px;
border: 1px solid var(--border-soft);
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(16px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
}}
.meta-key {{
font-size: 13px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-sub);
}}
.meta-value {{
margin-top: 6px;
font-size: 22px;
font-weight: 600;
}}
.guide-section {{
margin: 0 auto 56px;
padding: 0 8px;
}}
.section-header {{
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 28px;
}}
.section-header h2 {{
margin: 0;
font-size: clamp(26px, 3vw, 32px);
letter-spacing: 0.5px;
}}
.section-desc {{
margin: 0;
font-size: 16px;
color: var(--text-sub);
max-width: 640px;
}}
.entries-grid {{
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}}
.entries-list {{
display: flex;
flex-direction: column;
gap: 18px;
}}
.entry-card {{
display: flex;
gap: 18px;
border-radius: 20px;
padding: 24px;
border: 1px solid transparent;
background: var(--bg-card);
transition: border 0.2s ease, background 0.2s ease, transform 0.2s ease;
}}
.entry-card:hover {{
background: var(--bg-card-hover);
border-color: rgba(255, 255, 255, 0.18);
transform: translateY(-4px);
}}
.entry-icon {{
font-size: 36px;
}}
.entry-content {{
flex: 1;
}}
.entry-content header {{
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}}
.entry-content h3 {{
margin: 0;
font-size: 20px;
display: inline-flex;
align-items: center;
gap: 10px;
}}
.entry-badge {{
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.5px;
background: var(--accent-strong);
}}
.entry-tags {{
display: flex;
gap: 12px;
flex-wrap: wrap;
}}
.entry-tags-extra {{
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}}
.entry-id {{
background: rgba(255, 255, 255, 0.1);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
letter-spacing: 0.5px;
}}
.entry-category {{
font-size: 12px;
color: var(--text-sub);
}}
.entry-desc {{
margin: 0 0 12px;
color: var(--text-sub);
line-height: 1.6;
}}
.entry-meta {{
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
font-size: 13px;
color: var(--text-sub);
}}
.entry-meta li {{
display: flex;
justify-content: space-between;
}}
.entry-link {{
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 14px;
}}
.entry-tag {{
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 12px;
letter-spacing: 0.4px;
}}
.entry-details {{
margin-top: 14px;
display: grid;
gap: 10px;
}}
.entry-detail-paragraph {{
margin: 0;
color: var(--text-sub);
line-height: 1.6;
}}
.entry-detail-list, .entry-detail-steps {{
margin: 0;
padding-left: 20px;
color: var(--text-sub);
}}
.entry-detail-steps {{
list-style: none;
padding-left: 0;
}}
.entry-detail-steps li {{
display: grid;
grid-template-columns: 28px 1fr;
align-items: start;
gap: 10px;
margin-bottom: 6px;
}}
.entry-detail-steps .step-index {{
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.12);
font-size: 12px;
}}
.entry-detail-table {{
width: 100%;
border-collapse: collapse;
border-radius: 14px;
overflow: hidden;
}}
.entry-detail-table th,
.entry-detail-table td {{
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px 12px;
font-size: 13px;
text-align: left;
}}
.entry-detail-table th {{
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
}}
.entry-detail-table tr:nth-child(even) {{
background: rgba(255, 255, 255, 0.04);
}}
.empty-placeholder {{
padding: 40px;
text-align: center;
border-radius: 20px;
border: 1px dashed rgba(255, 255, 255, 0.2);
color: var(--text-sub);
}}
@media (max-width: 680px) {{
.entry-card {{
flex-direction: column;
}}
.entry-content h3 {{
font-size: 18px;
}}
.meta-grid {{
grid-template-columns: 1fr 1fr;
}}
}}
@media (max-width: 480px) {{
.meta-grid {{
grid-template-columns: 1fr;
}}
}}
</style>
</head>
<body>
<header class="hero">
<h1 class="hero-title">{escape(page.title)}</h1>
{subtitle_html}
</header>
<main>
{metadata_html}
{related_html}
{sections_html}
</main>
</body>
</html>
"""
class MessageSender:
"""消息发送器"""
@@ -190,6 +800,198 @@ class BasicWPSInterface(PluginInterface):
class WPSAPI(BasicWPSInterface):
"""核心 WPS 插件基类,提供图鉴模板设施。"""
guide_section_labels: Dict[str, str] = {
"commands": "指令一览",
"items": "物品与资源",
"recipes": "配方与合成",
"guides": "系统指引",
}
def get_guide_title(self) -> str:
return self.__class__.__name__
def get_guide_subtitle(self) -> str:
return ""
def get_guide_metadata(self) -> Dict[str, str]:
return {}
def collect_command_entries(self) -> Sequence[GuideEntry]:
return ()
def collect_item_entries(self) -> Sequence[GuideEntry]:
return ()
def collect_recipe_entries(self) -> Sequence[GuideEntry]:
return ()
def collect_guide_entries(self) -> Sequence[GuideEntry]:
return ()
def collect_additional_sections(self) -> Sequence[GuideSection]:
return ()
def collect_guide_sections(self) -> Sequence[GuideSection]:
sections: List[GuideSection] = []
command_entries = tuple(self.collect_command_entries())
if command_entries:
sections.append(
GuideSection(
title=self.guide_section_labels["commands"],
entries=command_entries,
layout="list",
)
)
item_entries = tuple(self.collect_item_entries())
if item_entries:
sections.append(
GuideSection(
title=self.guide_section_labels["items"],
entries=item_entries,
)
)
recipe_entries = tuple(self.collect_recipe_entries())
if recipe_entries:
sections.append(
GuideSection(
title=self.guide_section_labels["recipes"],
entries=recipe_entries,
)
)
guide_entries = tuple(self.collect_guide_entries())
if guide_entries:
sections.append(
GuideSection(
title=self.guide_section_labels["guides"],
entries=guide_entries,
layout="list",
)
)
additional_sections = tuple(self.collect_additional_sections())
if additional_sections:
sections.extend(additional_sections)
return tuple(sections)
def build_guide_page(self) -> GuidePage:
metadata: Dict[str, str] = {}
for key, value in self.get_guide_metadata().items():
metadata[key] = str(value)
related = self.get_related_links()
return GuidePage(
title=self.get_guide_title(),
subtitle=self.get_guide_subtitle(),
sections=self.collect_guide_sections(),
metadata=metadata,
related_links=related,
)
def render_guide_page(self, page: GuidePage) -> str:
return render_html_page(page)
def render_guide(self) -> str:
return self.render_guide_page(self.build_guide_page())
def get_guide_response(self, content: str) -> HTMLResponse:
return HTMLResponse(content)
@override
def generate_router_illustrated_guide(self):
async def handler() -> HTMLResponse:
return self.get_guide_response(self.render_guide())
return handler
def get_related_links(self) -> Dict[str, Sequence[Dict[str, str]]]:
links: Dict[str, Sequence[Dict[str, str]]] = {}
parents = []
for base in self.__class__.__mro__[1:]:
if not issubclass(base, WPSAPI):
continue
if base.__module__.startswith("Plugins."):
parents.append(base)
if base is WPSAPI:
break
if parents:
parents_links = [self._build_class_link(cls) for cls in reversed(parents)]
links["父类链"] = tuple(filter(None, parents_links))
child_links = [self._build_class_link(child) for child in self._iter_subclasses(self.__class__)]
child_links = [link for link in child_links if link]
if child_links:
links["子类"] = tuple(child_links)
return links
def _build_class_link(self, cls: type) -> Optional[Dict[str, str]]:
if not hasattr(cls, "__module__") or not cls.__module__.startswith("Plugins."):
return None
path = f"/api/{cls.__name__}"
return {
"label": cls.__name__,
"href": path,
}
def _iter_subclasses(self, cls: type) -> List[type]:
collected: List[type] = []
for subclass in cls.__subclasses__():
if not issubclass(subclass, WPSAPI):
continue
if not subclass.__module__.startswith("Plugins."):
continue
collected.append(subclass)
collected.extend(self._iter_subclasses(subclass))
return collected
def get_guide_subtitle(self) -> str:
return "核心 Webhook 转发插件"
def get_guide_metadata(self) -> Dict[str, str]:
return {
"Webhook 状态": "已配置" if MAIN_WEBHOOK_URL else "未配置",
}
def collect_command_entries(self) -> Sequence[GuideEntry]:
return (
{
"title": "say",
"identifier": "say",
"description": "将后续消息内容以 Markdown 形式发送到主 Webhook。",
"metadata": {"别名": ""},
"icon": "🗣️",
"badge": "核心",
},
)
def collect_guide_entries(self) -> Sequence[GuideEntry]:
return (
{
"title": "Webhook 绑定",
"description": (
"在项目配置中设置 `main_webhook_url` 后插件自动启用,"
"所有注册的命令将调用 `send_markdown_message` 发送富文本。"
),
"icon": "🔗",
},
{
"title": "消息格式",
"description": (
"默认使用 Markdown 模式发送,支持 `聊天ID` 与 `用户ID` 的 @ 提醒。"
),
"icon": "📝",
},
)
@override
def is_enable_plugin(self) -> bool:
if MAIN_WEBHOOK_URL == "":