feat(docs): structure the in-app help viewer to match the docs site

This commit is contained in:
matevip 2026-06-22 17:28:35 +08:00
parent 438a5e00d7
commit 30252a377d
3 changed files with 263 additions and 44 deletions

View File

@ -10,7 +10,11 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -35,6 +39,34 @@ public class MateClawDocService {
/** VitePress 首页,无正文,从用户可见列表中排除。 */
private static final String INDEX_SLUG = "index";
/** 一个文档分组:组标题(中/英)+ 该组内文档的有序 slug 列表。 */
private record DocGroup(String zhLabel, String enLabel, List<String> slugs) {}
/**
* 帮助文档的分组与顺序镜像 VitePress 文档站侧栏 (docs/.vitepress/config.ts)
* 开始 使用 扩展 运维 开发 参考
* 磁盘上存在但未登记于此的文档会被归入末尾的更多 / More分组不会丢失
* 也提示维护者把它补进对应分组改了 VitePress 侧栏时同步更新这里即可保持一致
*/
private static final List<DocGroup> STRUCTURE = List.of(
new DocGroup("开始", "Start",
List.of("intro", "quickstart", "desktop")),
new DocGroup("使用", "Use",
List.of("chat", "agents", "goals", "wiki", "memory", "multimodal", "model3d",
"channels", "webchat", "wecom-tuning", "ambient-ai", "workflow", "triggers")),
new DocGroup("扩展", "Extend",
List.of("tools", "skills", "mcp", "acp")),
new DocGroup("运维", "Operate",
List.of("console", "backstage", "docker-deploy", "workspaces", "security", "models", "doctor", "config")),
new DocGroup("开发", "Develop",
List.of("api", "architecture", "contributing")),
new DocGroup("参考", "Reference",
List.of("releases", "roadmap", "faq")));
/** 未登记文档的兜底分组标题。 */
private static final String OTHER_ZH = "更多";
private static final String OTHER_EN = "More";
/** 开头的 YAML frontmatter 块:`---\n ... \n---`。 */
private static final Pattern FRONTMATTER = Pattern.compile("^---\\s*\\n.*?\\n---\\s*\\n", Pattern.DOTALL);
/** frontmatter 里的 `title:` 字段。 */
@ -42,17 +74,18 @@ public class MateClawDocService {
/** 正文里的首个 ATX 一级标题 `# xxx`。 */
private static final Pattern H1 = Pattern.compile("(?m)^#\\s+(.+?)\\s*$");
public record DocMeta(String slug, String title) {}
public record DocMeta(String slug, String title, String group) {}
/**
* 列出某语言下的全部文档排除 index.md slug 排序
* 每篇带一个用于展示标题
* 列出某语言下的全部文档排除 index.md {@link #STRUCTURE} 的分组与顺序输出
* 每篇带展示标题和所属分组磁盘上存在但未登记的文档归入末尾更多分组按字母序
*/
public List<DocMeta> list(String lang) {
if (lang == null || !VALID_LANG.matcher(lang).matches()) {
return List.of();
}
List<DocMeta> docs = new ArrayList<>();
// 先扫描磁盘上实际存在的文档slug -> Resource保序作为兜底分组的输入
Map<String, Resource> available = new LinkedHashMap<>();
try {
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources("classpath:docs/" + lang + "/*.md");
@ -65,13 +98,34 @@ public class MateClawDocService {
if (INDEX_SLUG.equals(slug)) {
continue;
}
docs.add(new DocMeta(slug, resolveTitle(r, slug)));
available.put(slug, r);
}
} catch (IOException e) {
log.debug("No {} docs found: {}", lang, e.getMessage());
}
docs.sort((a, b) -> a.slug().compareTo(b.slug()));
return docs;
boolean en = "en".equals(lang);
List<DocMeta> ordered = new ArrayList<>();
Set<String> placed = new HashSet<>();
// 1) 按结构分组按顺序输出已存在的文档
for (DocGroup g : STRUCTURE) {
String label = en ? g.enLabel() : g.zhLabel();
for (String slug : g.slugs()) {
Resource r = available.get(slug);
if (r == null) {
continue;
}
ordered.add(new DocMeta(slug, resolveTitle(r, slug), label));
placed.add(slug);
}
}
// 2) 未登记于结构的文档归入更多分组按字母序避免遗漏
String otherLabel = en ? OTHER_EN : OTHER_ZH;
available.entrySet().stream()
.filter(e -> !placed.contains(e.getKey()))
.sorted(Map.Entry.comparingByKey())
.forEach(e -> ordered.add(new DocMeta(e.getKey(), resolveTitle(e.getValue(), e.getKey()), otherLabel)));
return ordered;
}
/**
@ -132,6 +186,12 @@ public class MateClawDocService {
if (raw == null) {
return slug;
}
// 侧栏要简洁标题优先正文首个 H1LLM Wiki 知识库
// 再退回 frontmatter title可能是较长的 SEO 标题最后退回 slug
Matcher h1 = H1.matcher(stripFrontmatter(raw));
if (h1.find()) {
return h1.group(1).trim();
}
Matcher fm = FRONTMATTER.matcher(raw);
if (fm.find()) {
Matcher title = TITLE_FIELD.matcher(fm.group());
@ -139,10 +199,6 @@ public class MateClawDocService {
return unquote(title.group(1));
}
}
Matcher h1 = H1.matcher(stripFrontmatter(raw));
if (h1.find()) {
return h1.group(1).trim();
}
return slug;
}

View File

@ -1457,6 +1457,8 @@ export const approvalApi = {
export interface DocMeta {
slug: string
title: string
/** Group label (e.g. 开始 / 使用 / 扩展), mirroring the docs site sidebar sections. */
group: string
}
export interface DocContent {

View File

@ -1,17 +1,22 @@
<template>
<div class="docs-page">
<aside class="docs-sidebar">
<div class="mc-page-shell docs-shell">
<div class="mc-page-frame docs-frame">
<div class="docs-page">
<aside class="docs-sidebar">
<div class="docs-sidebar__title">{{ t('docs.title') }}</div>
<nav class="docs-nav">
<button
v-for="doc in docs"
:key="doc.slug"
class="docs-nav__item"
:class="{ 'docs-nav__item--active': doc.slug === activeSlug }"
@click="selectDoc(doc.slug)"
>
{{ doc.title }}
</button>
<div v-for="group in groupedDocs" :key="group.label" class="docs-nav__group">
<div class="docs-nav__group-title">{{ group.label }}</div>
<button
v-for="doc in group.items"
:key="doc.slug"
class="docs-nav__item"
:class="{ 'docs-nav__item--active': doc.slug === activeSlug }"
@click="selectDoc(doc.slug)"
>
{{ doc.title }}
</button>
</div>
</nav>
</aside>
@ -26,6 +31,8 @@
@click="onContentClick"
/>
</main>
</div>
</div>
</div>
</template>
@ -54,6 +61,20 @@ const lang = computed(() => (locale.value.startsWith('en') ? 'en' : 'zh'))
// Wikilink chat [[...]]
const rendered = computed(() => renderMarkdown(content.value, { wikilink: 'none' }))
// / 使 /
const groupedDocs = computed(() => {
const groups: { label: string; items: DocMeta[] }[] = []
for (const doc of docs.value) {
const last = groups[groups.length - 1]
if (last && last.label === doc.group) {
last.items.push(doc)
} else {
groups.push({ label: doc.group, items: [doc] })
}
}
return groups
})
async function loadList() {
try {
const res: any = await docsApi.list(lang.value)
@ -133,27 +154,43 @@ onMounted(async () => {
</script>
<style scoped>
/* Wrap the viewer in the shared bordered page frame (same as Agents/Chat),
but full-height with internal scrolling rather than a scrolling shell. */
.docs-shell {
background: transparent;
min-height: 0;
height: 100%;
overflow: hidden;
}
.docs-frame {
height: min(calc(100vh - 28px), 100%);
min-height: 0;
overflow: hidden;
}
.docs-page {
position: relative;
z-index: 1;
display: flex;
height: 100%;
overflow: hidden;
}
.docs-sidebar {
width: 240px;
width: 248px;
flex-shrink: 0;
border-right: 1px solid var(--border-color, #e5e7eb);
border-right: 1px solid var(--mc-border-light);
overflow-y: auto;
padding: 16px 8px;
padding: 18px 10px 24px;
}
.docs-sidebar__title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #6b7280);
padding: 0 12px 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 12px;
font-weight: 700;
color: var(--mc-text-secondary);
padding: 2px 12px 14px;
letter-spacing: 0.02em;
}
.docs-nav {
@ -162,46 +199,170 @@ onMounted(async () => {
gap: 2px;
}
.docs-nav__group {
display: flex;
flex-direction: column;
gap: 1px;
}
.docs-nav__group + .docs-nav__group {
margin-top: 16px;
}
.docs-nav__group-title {
font-size: 11px;
font-weight: 700;
color: var(--mc-text-tertiary);
padding: 4px 12px 6px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.docs-nav__item {
position: relative;
text-align: left;
border: none;
background: transparent;
color: var(--text-primary, #111827);
padding: 7px 12px;
border-radius: 6px;
font-size: 14px;
color: var(--mc-text-secondary);
padding: 8px 12px;
border-radius: var(--mc-radius-md);
font-size: 13.5px;
line-height: 1.45;
cursor: pointer;
transition: background 0.12s;
transition: background 0.15s ease, color 0.15s ease;
}
.docs-nav__item:hover {
background: var(--hover-bg, #f3f4f6);
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
.docs-nav__item--active {
background: var(--active-bg, #eef2ff);
color: var(--primary-color, #4f46e5);
.docs-nav__item--active,
.docs-nav__item--active:hover {
background: var(--mc-primary-bg);
color: var(--mc-primary);
font-weight: 600;
box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.1);
}
.docs-content {
flex: 1;
overflow-y: auto;
padding: 28px 40px;
padding: 36px 56px 80px;
}
.docs-article {
max-width: 860px;
max-width: 784px;
margin: 0 auto;
font-size: 15px;
line-height: 1.78;
color: var(--mc-text-secondary);
}
/* —— 文档阅读排版scoped :deep 仅作用于文档页,不影响聊天共用的 .markdown-body —— */
.docs-article :deep(h1) {
font-size: 1.9em;
font-weight: 800;
letter-spacing: -0.01em;
line-height: 1.25;
color: var(--mc-text-primary);
margin: 0 0 20px;
}
.docs-article :deep(h2) {
font-size: 1.42em;
font-weight: 700;
line-height: 1.35;
color: var(--mc-text-primary);
margin: 42px 0 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--mc-border-light);
scroll-margin-top: 16px;
}
.docs-article :deep(h3) {
font-size: 1.18em;
font-weight: 700;
color: var(--mc-text-primary);
margin: 28px 0 10px;
scroll-margin-top: 16px;
}
.docs-article :deep(h4) {
font-size: 1.02em;
font-weight: 700;
color: var(--mc-text-primary);
margin: 22px 0 8px;
}
.docs-article :deep(h1:first-child),
.docs-article :deep(h2:first-child),
.docs-article :deep(h3:first-child) {
margin-top: 0;
}
.docs-article :deep(p) {
margin: 14px 0;
}
.docs-article :deep(ul),
.docs-article :deep(ol) {
margin: 14px 0;
padding-left: 1.5em;
}
.docs-article :deep(li) {
margin: 6px 0;
}
.docs-article :deep(li > ul),
.docs-article :deep(li > ol) {
margin: 6px 0;
}
.docs-article :deep(a) {
color: var(--mc-primary);
font-weight: 500;
text-decoration: none;
}
.docs-article :deep(a:hover) {
color: var(--mc-primary-hover);
text-decoration: underline;
}
.docs-article :deep(strong) {
color: var(--mc-text-primary);
font-weight: 700;
}
.docs-article :deep(hr) {
border: none;
border-top: 1px solid var(--mc-border-light);
margin: 36px 0;
}
.docs-article :deep(blockquote) {
margin: 18px 0;
padding: 4px 16px;
border-left: 3px solid var(--mc-primary);
background: var(--mc-bg-muted);
border-radius: 0 var(--mc-radius-md) var(--mc-radius-md) 0;
color: var(--mc-text-secondary);
}
.docs-article :deep(pre) {
margin: 18px 0;
border: 1px solid var(--mc-border-light);
}
.docs-article :deep(table) {
margin: 18px 0;
font-size: 0.95em;
}
.docs-article :deep(img) {
max-width: 100%;
border-radius: var(--mc-radius-md);
}
@media (max-width: 768px) {
.docs-content {
padding: 24px 20px 64px;
}
}
.docs-state {
color: var(--text-secondary, #6b7280);
color: var(--mc-text-secondary);
padding: 40px;
text-align: center;
}
.docs-state--error {
color: var(--danger-color, #dc2626);
color: var(--mc-danger);
}
</style>