新增插件指引网页
This commit is contained in:
@@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
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 == "":
|
||||
|
||||
Reference in New Issue
Block a user