feat: add HelpModule (HelpController, HelpService) for standardized FAQ/HELP system
HelpModule auto-discovers articles from help/*.md files or articles.json.
Exposes GET /api/help (articles list) and GET /api/help/:id (markdown content).
Supports i18n via {id}.{lang}.md file pattern.
This commit is contained in:
parent
4c31519308
commit
b4d214d9c3
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@erp/plugin-sdk",
|
"name": "@erp/plugin-sdk",
|
||||||
"version": "1.9.1",
|
"version": "1.10.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@erp/plugin-sdk",
|
"name": "@erp/plugin-sdk",
|
||||||
"version": "1.9.1",
|
"version": "1.10.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@erp/plugin-sdk",
|
"name": "@erp/plugin-sdk",
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
|
import { HelpService, HelpArticle } from './help.service';
|
||||||
|
|
||||||
|
@Controller('api/help')
|
||||||
|
export class HelpController {
|
||||||
|
constructor(private readonly helpService: HelpService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getArticles(): { articles: HelpArticle[] } {
|
||||||
|
return { articles: this.helpService.getArticles() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
getArticle(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('lang') lang?: string,
|
||||||
|
): string {
|
||||||
|
const language = lang || 'en';
|
||||||
|
const content = this.helpService.getArticleContent(id, language);
|
||||||
|
if (content === null) {
|
||||||
|
return `# Not Found\nHelp article "${id}" not found for language "${language}".`;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HelpController } from './help.controller';
|
||||||
|
import { HelpService } from './help.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HelpController],
|
||||||
|
providers: [HelpService],
|
||||||
|
exports: [HelpService],
|
||||||
|
})
|
||||||
|
export class HelpModule {}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface HelpArticle {
|
||||||
|
id: string;
|
||||||
|
title: string | Record<string, string>;
|
||||||
|
description?: string | Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelpArticlesJson {
|
||||||
|
articles: HelpArticle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HelpService {
|
||||||
|
private readonly logger = new Logger(HelpService.name);
|
||||||
|
private readonly helpDir: string;
|
||||||
|
private cachedArticles: HelpArticle[] | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.helpDir = path.resolve(process.cwd(), 'help');
|
||||||
|
}
|
||||||
|
|
||||||
|
getArticles(): HelpArticle[] {
|
||||||
|
if (this.cachedArticles) return this.cachedArticles;
|
||||||
|
|
||||||
|
if (!fs.existsSync(this.helpDir)) {
|
||||||
|
this.logger.warn(`Help directory not found: ${this.helpDir}`);
|
||||||
|
this.cachedArticles = [];
|
||||||
|
return this.cachedArticles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try articles.json first
|
||||||
|
const jsonPath = path.join(this.helpDir, 'articles.json');
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(jsonPath, 'utf-8');
|
||||||
|
const parsed: HelpArticlesJson = JSON.parse(raw);
|
||||||
|
if (parsed.articles && Array.isArray(parsed.articles)) {
|
||||||
|
this.cachedArticles = parsed.articles;
|
||||||
|
return this.cachedArticles;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`Failed to parse articles.json: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: auto-discover from .md files
|
||||||
|
return this.discoverArticlesFromFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
getArticleContent(id: string, lang: string): string | null {
|
||||||
|
if (!fs.existsSync(this.helpDir)) return null;
|
||||||
|
|
||||||
|
// Try lang-specific file first: help/{id}.{lang}.md
|
||||||
|
const langPath = path.join(this.helpDir, `${id}.${lang}.md`);
|
||||||
|
if (fs.existsSync(langPath)) {
|
||||||
|
return fs.readFileSync(langPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to .en.md
|
||||||
|
const enPath = path.join(this.helpDir, `${id}.en.md`);
|
||||||
|
if (fs.existsSync(enPath)) {
|
||||||
|
return fs.readFileSync(enPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to plain {id}.md
|
||||||
|
const plainPath = path.join(this.helpDir, `${id}.md`);
|
||||||
|
if (fs.existsSync(plainPath)) {
|
||||||
|
return fs.readFileSync(plainPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to any matching file
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(this.helpDir);
|
||||||
|
const match = files.find(f => f.startsWith(`${id}.`) && f.endsWith('.md'));
|
||||||
|
if (match) {
|
||||||
|
return fs.readFileSync(path.join(this.helpDir, match), 'utf-8');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.cachedArticles = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private discoverArticlesFromFiles(): HelpArticle[] {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(this.helpDir).filter(f => f.endsWith('.md'));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const articles: HelpArticle[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Pattern: {id}.{lang}.md or {id}.md
|
||||||
|
const baseName = file.replace(/\.md$/, '');
|
||||||
|
const parts = baseName.split('.');
|
||||||
|
let id: string;
|
||||||
|
|
||||||
|
if (parts.length >= 2 && parts[parts.length - 1].length === 2) {
|
||||||
|
// Has language suffix: getting-started.en.md
|
||||||
|
id = parts.slice(0, -1).join('.');
|
||||||
|
} else {
|
||||||
|
// Plain name: getting-started.md
|
||||||
|
id = baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
|
||||||
|
// Extract title from first h1
|
||||||
|
const content = fs.readFileSync(path.join(this.helpDir, file), 'utf-8');
|
||||||
|
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||||
|
const title = titleMatch ? titleMatch[1].trim() : id;
|
||||||
|
const descMatch = content.match(/^>\s+(.+)$/m);
|
||||||
|
const description = descMatch ? descMatch[1].trim() : undefined;
|
||||||
|
|
||||||
|
articles.push({ id, title, ...(description ? { description } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return articles;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`Failed to discover help articles: ${e.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { HelpModule } from './help.module';
|
||||||
|
export { HelpService, HelpArticle } from './help.service';
|
||||||
|
export { HelpController } from './help.controller';
|
||||||
|
|
@ -9,3 +9,4 @@ export * from './backend/interceptors/request-context.interceptor';
|
||||||
export * from './backend/interceptors/transform.interceptor';
|
export * from './backend/interceptors/transform.interceptor';
|
||||||
export * from './backend/config/rabbitmq.config';
|
export * from './backend/config/rabbitmq.config';
|
||||||
export * from './backend/services/attribute-access.service';
|
export * from './backend/services/attribute-access.service';
|
||||||
|
export { HelpModule, HelpService } from './backend/help';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue