mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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.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;
|
||||
}
|
||||
// 侧栏要简洁标题:优先正文首个 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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user