mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
feat(docs): structure the in-app help viewer to match the docs site
This commit is contained in:
parent
438a5e00d7
commit
30252a377d
@ -10,7 +10,11 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@ -35,6 +39,34 @@ public class MateClawDocService {
|
|||||||
/** VitePress 首页,无正文,从用户可见列表中排除。 */
|
/** VitePress 首页,无正文,从用户可见列表中排除。 */
|
||||||
private static final String INDEX_SLUG = "index";
|
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---`。 */
|
/** 开头的 YAML frontmatter 块:`---\n ... \n---`。 */
|
||||||
private static final Pattern FRONTMATTER = Pattern.compile("^---\\s*\\n.*?\\n---\\s*\\n", Pattern.DOTALL);
|
private static final Pattern FRONTMATTER = Pattern.compile("^---\\s*\\n.*?\\n---\\s*\\n", Pattern.DOTALL);
|
||||||
/** frontmatter 里的 `title:` 字段。 */
|
/** frontmatter 里的 `title:` 字段。 */
|
||||||
@ -42,17 +74,18 @@ public class MateClawDocService {
|
|||||||
/** 正文里的首个 ATX 一级标题 `# xxx`。 */
|
/** 正文里的首个 ATX 一级标题 `# xxx`。 */
|
||||||
private static final Pattern H1 = Pattern.compile("(?m)^#\\s+(.+?)\\s*$");
|
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) {
|
public List<DocMeta> list(String lang) {
|
||||||
if (lang == null || !VALID_LANG.matcher(lang).matches()) {
|
if (lang == null || !VALID_LANG.matcher(lang).matches()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
List<DocMeta> docs = new ArrayList<>();
|
// 先扫描磁盘上实际存在的文档:slug -> Resource(保序,作为兜底分组的输入)。
|
||||||
|
Map<String, Resource> available = new LinkedHashMap<>();
|
||||||
try {
|
try {
|
||||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||||
Resource[] resources = resolver.getResources("classpath:docs/" + lang + "/*.md");
|
Resource[] resources = resolver.getResources("classpath:docs/" + lang + "/*.md");
|
||||||
@ -65,13 +98,34 @@ public class MateClawDocService {
|
|||||||
if (INDEX_SLUG.equals(slug)) {
|
if (INDEX_SLUG.equals(slug)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
docs.add(new DocMeta(slug, resolveTitle(r, slug)));
|
available.put(slug, r);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.debug("No {} docs found: {}", lang, e.getMessage());
|
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) {
|
if (raw == null) {
|
||||||
return slug;
|
return slug;
|
||||||
}
|
}
|
||||||
|
// 侧栏要简洁标题:优先正文首个 H1(如「LLM Wiki 知识库」),
|
||||||
|
// 再退回 frontmatter 的 title(可能是较长的 SEO 标题),最后退回 slug。
|
||||||
|
Matcher h1 = H1.matcher(stripFrontmatter(raw));
|
||||||
|
if (h1.find()) {
|
||||||
|
return h1.group(1).trim();
|
||||||
|
}
|
||||||
Matcher fm = FRONTMATTER.matcher(raw);
|
Matcher fm = FRONTMATTER.matcher(raw);
|
||||||
if (fm.find()) {
|
if (fm.find()) {
|
||||||
Matcher title = TITLE_FIELD.matcher(fm.group());
|
Matcher title = TITLE_FIELD.matcher(fm.group());
|
||||||
@ -139,10 +199,6 @@ public class MateClawDocService {
|
|||||||
return unquote(title.group(1));
|
return unquote(title.group(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Matcher h1 = H1.matcher(stripFrontmatter(raw));
|
|
||||||
if (h1.find()) {
|
|
||||||
return h1.group(1).trim();
|
|
||||||
}
|
|
||||||
return slug;
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1457,6 +1457,8 @@ export const approvalApi = {
|
|||||||
export interface DocMeta {
|
export interface DocMeta {
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
|
/** Group label (e.g. 开始 / 使用 / 扩展), mirroring the docs site sidebar sections. */
|
||||||
|
group: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocContent {
|
export interface DocContent {
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="docs-page">
|
<div class="mc-page-shell docs-shell">
|
||||||
<aside class="docs-sidebar">
|
<div class="mc-page-frame docs-frame">
|
||||||
|
<div class="docs-page">
|
||||||
|
<aside class="docs-sidebar">
|
||||||
<div class="docs-sidebar__title">{{ t('docs.title') }}</div>
|
<div class="docs-sidebar__title">{{ t('docs.title') }}</div>
|
||||||
<nav class="docs-nav">
|
<nav class="docs-nav">
|
||||||
<button
|
<div v-for="group in groupedDocs" :key="group.label" class="docs-nav__group">
|
||||||
v-for="doc in docs"
|
<div class="docs-nav__group-title">{{ group.label }}</div>
|
||||||
:key="doc.slug"
|
<button
|
||||||
class="docs-nav__item"
|
v-for="doc in group.items"
|
||||||
:class="{ 'docs-nav__item--active': doc.slug === activeSlug }"
|
:key="doc.slug"
|
||||||
@click="selectDoc(doc.slug)"
|
class="docs-nav__item"
|
||||||
>
|
:class="{ 'docs-nav__item--active': doc.slug === activeSlug }"
|
||||||
{{ doc.title }}
|
@click="selectDoc(doc.slug)"
|
||||||
</button>
|
>
|
||||||
|
{{ doc.title }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -26,6 +31,8 @@
|
|||||||
@click="onContentClick"
|
@click="onContentClick"
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -54,6 +61,20 @@ const lang = computed(() => (locale.value.startsWith('en') ? 'en' : 'zh'))
|
|||||||
// Wikilink 替换是 chat 专用语义,文档里不需要;关掉避免误伤 [[...]] 文本。
|
// Wikilink 替换是 chat 专用语义,文档里不需要;关掉避免误伤 [[...]] 文本。
|
||||||
const rendered = computed(() => renderMarkdown(content.value, { wikilink: 'none' }))
|
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() {
|
async function loadList() {
|
||||||
try {
|
try {
|
||||||
const res: any = await docsApi.list(lang.value)
|
const res: any = await docsApi.list(lang.value)
|
||||||
@ -133,27 +154,43 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.docs-page {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sidebar {
|
.docs-sidebar {
|
||||||
width: 240px;
|
width: 248px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-right: 1px solid var(--border-color, #e5e7eb);
|
border-right: 1px solid var(--mc-border-light);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 8px;
|
padding: 18px 10px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sidebar__title {
|
.docs-sidebar__title {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-secondary, #6b7280);
|
color: var(--mc-text-secondary);
|
||||||
padding: 0 12px 8px;
|
padding: 2px 12px 14px;
|
||||||
text-transform: uppercase;
|
letter-spacing: 0.02em;
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-nav {
|
.docs-nav {
|
||||||
@ -162,46 +199,170 @@ onMounted(async () => {
|
|||||||
gap: 2px;
|
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 {
|
.docs-nav__item {
|
||||||
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary, #111827);
|
color: var(--mc-text-secondary);
|
||||||
padding: 7px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 6px;
|
border-radius: var(--mc-radius-md);
|
||||||
font-size: 14px;
|
font-size: 13.5px;
|
||||||
|
line-height: 1.45;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.12s;
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-nav__item:hover {
|
.docs-nav__item:hover {
|
||||||
background: var(--hover-bg, #f3f4f6);
|
background: var(--mc-bg-muted);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-nav__item--active {
|
.docs-nav__item--active,
|
||||||
background: var(--active-bg, #eef2ff);
|
.docs-nav__item--active:hover {
|
||||||
color: var(--primary-color, #4f46e5);
|
background: var(--mc-primary-bg);
|
||||||
|
color: var(--mc-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-content {
|
.docs-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 28px 40px;
|
padding: 36px 56px 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-article {
|
.docs-article {
|
||||||
max-width: 860px;
|
max-width: 784px;
|
||||||
margin: 0 auto;
|
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 {
|
.docs-state {
|
||||||
color: var(--text-secondary, #6b7280);
|
color: var(--mc-text-secondary);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-state--error {
|
.docs-state--error {
|
||||||
color: var(--danger-color, #dc2626);
|
color: var(--mc-danger);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user