mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
parent
a0eba17688
commit
7c4380a116
@ -0,0 +1,73 @@
|
||||
package vip.mate.doc;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.tool.builtin.MateClawDocService;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 内置帮助文档的只读接口,供前端文档查看器消费。
|
||||
*
|
||||
* <p>文档本体打包在 classpath:docs/{zh,en}/ 下,与给智能体用的
|
||||
* {@link MateClawDocService} 共享同一套扫描/校验逻辑。
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Docs")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/docs")
|
||||
@RequiredArgsConstructor
|
||||
public class DocController {
|
||||
|
||||
private final MateClawDocService docService;
|
||||
|
||||
@Operation(summary = "列出某语言下的全部帮助文档(slug + 标题)")
|
||||
@GetMapping
|
||||
public R<List<MateClawDocService.DocMeta>> list(
|
||||
@RequestParam(defaultValue = "zh") String lang) {
|
||||
return R.ok(docService.list(normalizeLang(lang)));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取单篇帮助文档正文(已剥离 frontmatter)")
|
||||
@GetMapping("/content")
|
||||
public R<Map<String, Object>> content(
|
||||
@RequestParam(defaultValue = "zh") String lang,
|
||||
@RequestParam String slug) {
|
||||
String normLang = normalizeLang(lang);
|
||||
String body = docService.read(normLang, slug);
|
||||
if (body == null) {
|
||||
return R.fail(404, "Document not found");
|
||||
}
|
||||
String title = docService.list(normLang).stream()
|
||||
.filter(d -> d.slug().equals(slug))
|
||||
.map(MateClawDocService.DocMeta::title)
|
||||
.findFirst()
|
||||
.orElse(slug);
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("slug", slug);
|
||||
payload.put("title", title);
|
||||
payload.put("content", body);
|
||||
return R.ok(payload);
|
||||
}
|
||||
|
||||
private String normalizeLang(String lang) {
|
||||
if (lang == null) {
|
||||
return "zh";
|
||||
}
|
||||
String l = lang.toLowerCase();
|
||||
// 前端 locale 形如 zh-CN / en-US,取主语言段。
|
||||
if (l.startsWith("en")) {
|
||||
return "en";
|
||||
}
|
||||
return "zh";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 内置项目文档(classpath:docs/{zh,en}/*.md)的读取服务。
|
||||
*
|
||||
* <p>同时服务两类消费方:给智能体运行时用的 {@link MateClawDocTool},以及给前端
|
||||
* 文档查看器用的 REST 接口。把 classpath 扫描、路径白名单校验、frontmatter 剥离
|
||||
* 等逻辑收敛在这里,避免两处重复。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MateClawDocService {
|
||||
|
||||
/** 合法语言目录。 */
|
||||
private static final Pattern VALID_LANG = Pattern.compile("^(zh|en)$");
|
||||
/** 合法 slug —— 仅小写字母、数字、连字符、下划线,禁止路径穿越。 */
|
||||
private static final Pattern VALID_SLUG = Pattern.compile("^[a-z0-9_-]+$");
|
||||
/** 兼容 MateClawDocTool 的旧式 "lang/slug.md" 路径。 */
|
||||
private static final Pattern VALID_PATH = Pattern.compile("^(zh|en)/[a-z0-9_-]+\\.md$");
|
||||
private static final String DOCS_BASE = "docs/";
|
||||
/** VitePress 首页,无正文,从用户可见列表中排除。 */
|
||||
private static final String INDEX_SLUG = "index";
|
||||
|
||||
/** 开头的 YAML frontmatter 块:`---\n ... \n---`。 */
|
||||
private static final Pattern FRONTMATTER = Pattern.compile("^---\\s*\\n.*?\\n---\\s*\\n", Pattern.DOTALL);
|
||||
/** frontmatter 里的 `title:` 字段。 */
|
||||
private static final Pattern TITLE_FIELD = Pattern.compile("(?m)^title:\\s*(.+?)\\s*$");
|
||||
/** 正文里的首个 ATX 一级标题 `# xxx`。 */
|
||||
private static final Pattern H1 = Pattern.compile("(?m)^#\\s+(.+?)\\s*$");
|
||||
|
||||
public record DocMeta(String slug, String title) {}
|
||||
|
||||
/**
|
||||
* 列出某语言下的全部文档(排除 index.md),按 slug 排序,
|
||||
* 每篇带一个用于展示的标题。
|
||||
*/
|
||||
public List<DocMeta> list(String lang) {
|
||||
if (lang == null || !VALID_LANG.matcher(lang).matches()) {
|
||||
return List.of();
|
||||
}
|
||||
List<DocMeta> docs = new ArrayList<>();
|
||||
try {
|
||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||
Resource[] resources = resolver.getResources("classpath:docs/" + lang + "/*.md");
|
||||
for (Resource r : resources) {
|
||||
String filename = r.getFilename();
|
||||
if (filename == null || !filename.endsWith(".md")) {
|
||||
continue;
|
||||
}
|
||||
String slug = filename.substring(0, filename.length() - ".md".length());
|
||||
if (INDEX_SLUG.equals(slug)) {
|
||||
continue;
|
||||
}
|
||||
docs.add(new DocMeta(slug, resolveTitle(r, slug)));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("No {} docs found: {}", lang, e.getMessage());
|
||||
}
|
||||
docs.sort((a, b) -> a.slug().compareTo(b.slug()));
|
||||
return docs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 (lang, slug) 对应文档的正文,剥离开头的 YAML frontmatter。
|
||||
*
|
||||
* @return 正文内容;找不到或参数非法时返回 {@code null}。
|
||||
*/
|
||||
public String read(String lang, String slug) {
|
||||
if (lang == null || !VALID_LANG.matcher(lang).matches()) {
|
||||
return null;
|
||||
}
|
||||
if (slug == null || !VALID_SLUG.matcher(slug).matches()) {
|
||||
return null;
|
||||
}
|
||||
String raw = readRaw(lang + "/" + slug + ".md");
|
||||
return raw == null ? null : stripFrontmatter(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 "lang/slug.md" 形式读取原始文件内容(含 frontmatter),用于
|
||||
* {@link MateClawDocTool} 的 read action。返回错误字符串以保持其旧契约。
|
||||
*/
|
||||
String readRawForTool(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "Error: 'path' is required when action='read'. Example: 'zh/config.md'";
|
||||
}
|
||||
if (!VALID_PATH.matcher(path).matches()) {
|
||||
return "Error: Invalid path format. Expected pattern: (zh|en)/<topic>.md, e.g. 'zh/config.md'";
|
||||
}
|
||||
String raw = readRaw(path);
|
||||
return raw == null ? "Error: Document not found: " + path : raw;
|
||||
}
|
||||
|
||||
private String readRaw(String path) {
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource(DOCS_BASE + path);
|
||||
if (!resource.exists()) {
|
||||
return null;
|
||||
}
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
log.info("Read doc {}: {} bytes", path, content.length());
|
||||
return content;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read doc {}: {}", path, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveTitle(Resource resource, String slug) {
|
||||
String raw = null;
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
raw = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to read doc for title {}: {}", slug, e.getMessage());
|
||||
}
|
||||
if (raw == null) {
|
||||
return slug;
|
||||
}
|
||||
Matcher fm = FRONTMATTER.matcher(raw);
|
||||
if (fm.find()) {
|
||||
Matcher title = TITLE_FIELD.matcher(fm.group());
|
||||
if (title.find()) {
|
||||
return unquote(title.group(1));
|
||||
}
|
||||
}
|
||||
Matcher h1 = H1.matcher(stripFrontmatter(raw));
|
||||
if (h1.find()) {
|
||||
return h1.group(1).trim();
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
private static String stripFrontmatter(String raw) {
|
||||
Matcher m = FRONTMATTER.matcher(raw);
|
||||
return m.find() ? raw.substring(m.end()) : raw;
|
||||
}
|
||||
|
||||
private static String unquote(String s) {
|
||||
String t = s.trim();
|
||||
if (t.length() >= 2 && ((t.startsWith("\"") && t.endsWith("\"")) || (t.startsWith("'") && t.endsWith("'")))) {
|
||||
return t.substring(1, t.length() - 1).trim();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@ -2,19 +2,12 @@ package vip.mate.tool.builtin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* MateClaw 项目文档读取工具
|
||||
@ -22,10 +15,10 @@ import java.util.regex.Pattern;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MateClawDocTool {
|
||||
|
||||
private static final Pattern VALID_PATH = Pattern.compile("^(zh|en)/[a-z0-9_-]+\\.md$");
|
||||
private static final String DOCS_BASE = "docs/";
|
||||
private final MateClawDocService docService;
|
||||
|
||||
@Tool(description = """
|
||||
Read MateClaw project documentation.
|
||||
@ -50,100 +43,34 @@ public class MateClawDocTool {
|
||||
if ("list".equalsIgnoreCase(action)) {
|
||||
return listDocs();
|
||||
} else if ("read".equalsIgnoreCase(action)) {
|
||||
return readDoc(path);
|
||||
return docService.readRawForTool(path);
|
||||
} else {
|
||||
return "Error: Unknown action '" + action + "'. Use 'list' or 'read'.";
|
||||
}
|
||||
}
|
||||
|
||||
private String listDocs() {
|
||||
try {
|
||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||
List<String> zhDocs = new ArrayList<>();
|
||||
List<String> enDocs = new ArrayList<>();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("MateClaw Documentation\n\n");
|
||||
|
||||
// Scan zh/ docs
|
||||
try {
|
||||
Resource[] zhResources = resolver.getResources("classpath:docs/zh/*.md");
|
||||
for (Resource r : zhResources) {
|
||||
String filename = r.getFilename();
|
||||
if (filename != null) {
|
||||
zhDocs.add(filename);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("No zh docs found: {}", e.getMessage());
|
||||
}
|
||||
sb.append("## 中文文档 (zh/)\n");
|
||||
appendGroup(sb, "zh");
|
||||
|
||||
// Scan en/ docs
|
||||
try {
|
||||
Resource[] enResources = resolver.getResources("classpath:docs/en/*.md");
|
||||
for (Resource r : enResources) {
|
||||
String filename = r.getFilename();
|
||||
if (filename != null) {
|
||||
enDocs.add(filename);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("No en docs found: {}", e.getMessage());
|
||||
}
|
||||
sb.append("\n## English Docs (en/)\n");
|
||||
appendGroup(sb, "en");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("MateClaw Documentation\n\n");
|
||||
|
||||
sb.append("## 中文文档 (zh/)\n");
|
||||
if (zhDocs.isEmpty()) {
|
||||
sb.append(" (none)\n");
|
||||
} else {
|
||||
zhDocs.sort(String::compareTo);
|
||||
for (String doc : zhDocs) {
|
||||
sb.append(" - zh/").append(doc).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\n## English Docs (en/)\n");
|
||||
if (enDocs.isEmpty()) {
|
||||
sb.append(" (none)\n");
|
||||
} else {
|
||||
enDocs.sort(String::compareTo);
|
||||
for (String doc : enDocs) {
|
||||
sb.append(" - en/").append(doc).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\nUse readMateClawDoc(action=\"read\", path=\"zh/config.md\") to read a specific doc.");
|
||||
return sb.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to list docs: {}", e.getMessage());
|
||||
return "Error: Failed to list documentation files: " + e.getMessage();
|
||||
}
|
||||
sb.append("\nUse readMateClawDoc(action=\"read\", path=\"zh/config.md\") to read a specific doc.");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String readDoc(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "Error: 'path' is required when action='read'. Example: 'zh/config.md'";
|
||||
private void appendGroup(StringBuilder sb, String lang) {
|
||||
List<MateClawDocService.DocMeta> docs = docService.list(lang);
|
||||
if (docs.isEmpty()) {
|
||||
sb.append(" (none)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: validate path format
|
||||
if (!VALID_PATH.matcher(path).matches()) {
|
||||
return "Error: Invalid path format. Expected pattern: (zh|en)/<topic>.md, e.g. 'zh/config.md'";
|
||||
}
|
||||
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource(DOCS_BASE + path);
|
||||
if (!resource.exists()) {
|
||||
return "Error: Document not found: " + path;
|
||||
}
|
||||
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
log.info("Read doc {}: {} bytes", path, content.length());
|
||||
return content;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read doc {}: {}", path, e.getMessage());
|
||||
return "Error: Failed to read document: " + e.getMessage();
|
||||
for (MateClawDocService.DocMeta doc : docs) {
|
||||
sb.append(" - ").append(lang).append('/').append(doc.slug()).append(".md\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 针对内置文档服务的单元测试。直接读 classpath 上打包的真实文档
|
||||
* (src/main/resources/docs/{zh,en}/),不需要额外测试资源。
|
||||
*/
|
||||
class MateClawDocServiceTest {
|
||||
|
||||
private final MateClawDocService service = new MateClawDocService();
|
||||
|
||||
@Test
|
||||
@DisplayName("list(zh) 返回文档且排除 VitePress 首页 index.md")
|
||||
void listExcludesIndex() {
|
||||
List<MateClawDocService.DocMeta> docs = service.list("zh");
|
||||
|
||||
assertThat(docs).isNotEmpty();
|
||||
assertThat(docs).noneMatch(d -> d.slug().equals("index"));
|
||||
// config.md 一定存在,且标题取的是中文 H1 而非文件名。
|
||||
assertThat(docs)
|
||||
.filteredOn(d -> d.slug().equals("config"))
|
||||
.singleElement()
|
||||
.satisfies(d -> assertThat(d.title()).isNotBlank().isNotEqualTo("config"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("list 对非法语言返回空")
|
||||
void listRejectsInvalidLang() {
|
||||
assertThat(service.list("fr")).isEmpty();
|
||||
assertThat(service.list("../zh")).isEmpty();
|
||||
assertThat(service.list(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("read 剥离开头的 YAML frontmatter")
|
||||
void readStripsFrontmatter() {
|
||||
// wiki.md 带 frontmatter(title/description/head)。
|
||||
String body = service.read("zh", "wiki");
|
||||
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.stripLeading()).doesNotStartWith("---");
|
||||
// `name: keywords` 只出现在 frontmatter 的 head meta 里,剥离后不应残留。
|
||||
assertThat(body).doesNotContain("name: keywords");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("read 拒绝非法 slug / 路径穿越")
|
||||
void readRejectsInvalidSlug() {
|
||||
assertThat(service.read("zh", "../application")).isNull();
|
||||
assertThat(service.read("zh", "config.md")).isNull();
|
||||
assertThat(service.read("zh", "a/b")).isNull();
|
||||
assertThat(service.read("fr", "config")).isNull();
|
||||
assertThat(service.read("zh", "does-not-exist-xyz")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("readRawForTool 保留 frontmatter 并对非法路径返回错误串")
|
||||
void readRawForToolContract() {
|
||||
assertThat(service.readRawForTool("zh/config.md")).doesNotStartWith("Error:");
|
||||
assertThat(service.readRawForTool("../etc/passwd")).startsWith("Error:");
|
||||
assertThat(service.readRawForTool(null)).startsWith("Error:");
|
||||
}
|
||||
}
|
||||
@ -1426,3 +1426,26 @@ export const approvalApi = {
|
||||
limit?: number
|
||||
}) => http.get<ResolutionLog[]>('/approval/resolutions', { params }),
|
||||
}
|
||||
|
||||
// ==================== 内置帮助文档 ====================
|
||||
|
||||
export interface DocMeta {
|
||||
slug: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface DocContent {
|
||||
slug: string
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export const docsApi = {
|
||||
/** 列出某语言下的全部帮助文档(slug + 标题)。 */
|
||||
list: (lang: string) =>
|
||||
http.get<DocMeta[]>('/docs', { params: { lang } }),
|
||||
|
||||
/** 读取单篇文档正文(已剥离 frontmatter)。 */
|
||||
content: (lang: string, slug: string) =>
|
||||
http.get<DocContent>('/docs/content', { params: { lang, slug } }),
|
||||
}
|
||||
|
||||
@ -413,6 +413,9 @@ export default {
|
||||
timeMinutesAgo: '{n}m ago',
|
||||
timeHoursAgo: '{n}h ago',
|
||||
},
|
||||
docs: {
|
||||
title: 'Docs',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
chat: 'Chat',
|
||||
@ -439,6 +442,7 @@ export default {
|
||||
settingsGroup: 'Settings',
|
||||
agents: 'Employees',
|
||||
security: 'Security',
|
||||
docs: 'Docs',
|
||||
tokenUsage: 'Token Usage',
|
||||
cronJobs: 'Cron Jobs',
|
||||
scheduler: 'Scheduler',
|
||||
|
||||
@ -413,6 +413,9 @@ export default {
|
||||
timeMinutesAgo: '{n} 分钟前',
|
||||
timeHoursAgo: '{n} 小时前',
|
||||
},
|
||||
docs: {
|
||||
title: '帮助文档',
|
||||
},
|
||||
nav: {
|
||||
dashboard: '仪表盘',
|
||||
chat: '对话',
|
||||
@ -439,6 +442,7 @@ export default {
|
||||
settingsGroup: '设置',
|
||||
agents: '员工',
|
||||
security: '安全',
|
||||
docs: '帮助文档',
|
||||
tokenUsage: 'Token 统计',
|
||||
cronJobs: '定时任务',
|
||||
scheduler: '调度中心',
|
||||
|
||||
@ -66,6 +66,14 @@ const router = createRouter({
|
||||
component: () => import('@/views/Memory/index.vue'),
|
||||
meta: { title: 'Memory', requiredCapability: 'view:memory' },
|
||||
},
|
||||
{
|
||||
// 内置帮助文档查看器。:slug? 让刷新 / 收藏能恢复当前文档。
|
||||
// 不加 requiredCapability —— 所有登录用户可见。
|
||||
path: 'docs/:slug?',
|
||||
name: 'Docs',
|
||||
component: () => import('@/views/Docs/index.vue'),
|
||||
meta: { title: 'Docs' },
|
||||
},
|
||||
// ==================== Connect ====================
|
||||
{
|
||||
path: 'channels',
|
||||
|
||||
207
mateclaw-ui/src/views/Docs/index.vue
Normal file
207
mateclaw-ui/src/views/Docs/index.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<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>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="docs-content">
|
||||
<div v-if="loading" class="docs-state">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="error" class="docs-state docs-state--error">{{ error }}</div>
|
||||
<article
|
||||
v-else
|
||||
ref="contentEl"
|
||||
class="markdown-body docs-article"
|
||||
v-html="rendered"
|
||||
@click="onContentClick"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { docsApi, type DocMeta } from '@/api'
|
||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
|
||||
const docs = ref<DocMeta[]>([])
|
||||
const activeSlug = ref<string>('')
|
||||
const content = ref<string>('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string>('')
|
||||
const contentEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// 后端文档目录只分 zh / en,取 app locale 的主语言段。
|
||||
const lang = computed(() => (locale.value.startsWith('en') ? 'en' : 'zh'))
|
||||
|
||||
// Wikilink 替换是 chat 专用语义,文档里不需要;关掉避免误伤 [[...]] 文本。
|
||||
const rendered = computed(() => renderMarkdown(content.value, { wikilink: 'none' }))
|
||||
|
||||
async function loadList() {
|
||||
try {
|
||||
const res: any = await docsApi.list(lang.value)
|
||||
docs.value = res.data || []
|
||||
} catch (e) {
|
||||
docs.value = []
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContent(slug: string) {
|
||||
if (!slug) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res: any = await docsApi.content(lang.value, slug)
|
||||
content.value = res.data?.content || ''
|
||||
activeSlug.value = slug
|
||||
contentEl.value?.scrollTo({ top: 0 })
|
||||
} catch (e) {
|
||||
content.value = ''
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectDoc(slug: string) {
|
||||
if (slug === activeSlug.value) return
|
||||
router.push({ name: 'Docs', params: { slug } })
|
||||
}
|
||||
|
||||
// 拦截文档间的相对链接(如 ./security、models),转成 SPA 内导航,避免整页刷新。
|
||||
// 外链(target=_blank,由 renderer 标注)保持默认行为。
|
||||
function onContentClick(e: MouseEvent) {
|
||||
const anchor = (e.target as HTMLElement)?.closest('a')
|
||||
if (!anchor) return
|
||||
if (anchor.target === '_blank') return
|
||||
const href = anchor.getAttribute('href') || ''
|
||||
if (!href || href.startsWith('#')) return
|
||||
const m = /^(?:\.\/|\.\.\/)?([a-z0-9_-]+)(?:\.md)?(?:[#?].*)?$/i.exec(href)
|
||||
if (!m) return
|
||||
const slug = m[1].toLowerCase()
|
||||
if (!docs.value.some((d) => d.slug === slug)) return
|
||||
e.preventDefault()
|
||||
selectDoc(slug)
|
||||
}
|
||||
|
||||
// 路由 slug 变化 → 加载对应文档;无 slug 时回退到第一篇。
|
||||
watch(
|
||||
() => route.params.slug,
|
||||
(slug) => {
|
||||
const target = (slug as string) || docs.value[0]?.slug
|
||||
if (target && target !== activeSlug.value) {
|
||||
loadContent(target)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 切换 app 语言 → 重新拉目录并重载当前文档。
|
||||
watch(lang, async () => {
|
||||
await loadList()
|
||||
const target = activeSlug.value || docs.value[0]?.slug
|
||||
if (target) await loadContent(target)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadList()
|
||||
const slug = route.params.slug as string
|
||||
if (slug) {
|
||||
await loadContent(slug)
|
||||
} else if (docs.value[0]) {
|
||||
// 落到第一篇:改写 URL,由 slug watcher 负责加载(避免重复请求)。
|
||||
router.replace({ name: 'Docs', params: { slug: docs.value[0].slug } })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.docs-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-color, #e5e7eb);
|
||||
overflow-y: auto;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.docs-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.docs-nav__item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #111827);
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.docs-nav__item:hover {
|
||||
background: var(--hover-bg, #f3f4f6);
|
||||
}
|
||||
|
||||
.docs-nav__item--active {
|
||||
background: var(--active-bg, #eef2ff);
|
||||
color: var(--primary-color, #4f46e5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px 40px;
|
||||
}
|
||||
|
||||
.docs-article {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-state {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.docs-state--error {
|
||||
color: var(--danger-color, #dc2626);
|
||||
}
|
||||
</style>
|
||||
@ -534,6 +534,11 @@ const navGroups = computed(() => [
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
||||
requiredCapability: 'manage:security',
|
||||
},
|
||||
{
|
||||
path: '/docs',
|
||||
label: t('nav.docs'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
|
||||
},
|
||||
] as NavItem[]),
|
||||
},
|
||||
].filter((group) => group.items.length > 0))
|
||||
@ -554,6 +559,9 @@ function isNavItemActive(item: { path: string; label: string }) {
|
||||
if (item.path === '/security') {
|
||||
return route.path.startsWith('/security')
|
||||
}
|
||||
if (item.path === '/docs') {
|
||||
return route.path.startsWith('/docs')
|
||||
}
|
||||
return route.path === item.path
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user