feat(H): H-GRAPH/H-EDITOR/H-BROWSE/H-ASK — frontend knowledge components (Wiki page, editor, browser, knowledge graph, ask-knowledge), i18n updates, tsc clean
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Wiki page — knowledge articles with categories, Markdown editor, versioning.
|
||||
*
|
||||
* Combines WikiBrowser (category tree + article list + search), article detail
|
||||
* with Markdown preview, create/edit modal, delete, and version history with
|
||||
* restore.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CalendarDays, History, Loader2, Pencil, Plus, Trash2, Undo2 } from 'lucide-react';
|
||||
import {
|
||||
createWikiArticle,
|
||||
deleteWikiArticle,
|
||||
fetchWikiArticle,
|
||||
fetchWikiCategories,
|
||||
fetchWikiVersions,
|
||||
restoreWikiVersion,
|
||||
updateWikiArticle,
|
||||
type WikiArticle,
|
||||
type WikiArticleStatus,
|
||||
type WikiCategory,
|
||||
type WikiVersion,
|
||||
} from '@/api/knowledge';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { WikiBrowser } from '@/components/wiki/WikiBrowser';
|
||||
import { WikiEditor } from '@/components/wiki/WikiEditor';
|
||||
|
||||
interface ArticleForm {
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
category_id: string;
|
||||
tags: string;
|
||||
status: WikiArticleStatus;
|
||||
}
|
||||
|
||||
const emptyForm: ArticleForm = {
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
summary: '',
|
||||
category_id: '',
|
||||
tags: '',
|
||||
status: 'draft',
|
||||
};
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export function WikiPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(null);
|
||||
const [article, setArticle] = useState<WikiArticle | null>(null);
|
||||
const [loadingArticle, setLoadingArticle] = useState(false);
|
||||
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
|
||||
const [versions, setVersions] = useState<WikiVersion[]>([]);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ArticleForm>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<WikiArticle | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
try {
|
||||
const cats = await fetchWikiCategories();
|
||||
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
|
||||
} catch {
|
||||
// Non-fatal — categories are optional in the editor
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
const loadArticle = useCallback(async (id: string) => {
|
||||
setLoadingArticle(true);
|
||||
try {
|
||||
const data = await fetchWikiArticle(id);
|
||||
setArticle(data);
|
||||
setSelectedArticleId(data.id);
|
||||
setShowVersions(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wiki.detail.loadError'));
|
||||
} finally {
|
||||
setLoadingArticle(false);
|
||||
}
|
||||
}, [t, toast]);
|
||||
|
||||
const handleSelectArticle = useCallback(
|
||||
(a: WikiArticle) => {
|
||||
void loadArticle(a.id);
|
||||
},
|
||||
[loadArticle]
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setEditorOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback(() => {
|
||||
if (!article) return;
|
||||
setEditingId(article.id);
|
||||
setForm({
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
content: article.content,
|
||||
summary: article.summary ?? '',
|
||||
category_id: article.category_id ?? '',
|
||||
tags: article.tags.join(', '),
|
||||
status: article.status,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
}, [article]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.title.trim()) {
|
||||
toast.error(t('wiki.editor.titleRequired'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
slug: form.slug.trim() || undefined,
|
||||
content: form.content,
|
||||
summary: form.summary.trim() || null,
|
||||
category_id: form.category_id || null,
|
||||
tags: form.tags.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
status: form.status,
|
||||
};
|
||||
if (editingId) {
|
||||
const updated = await updateWikiArticle(editingId, payload);
|
||||
setArticle(updated);
|
||||
toast.success(t('knowledge.editor.updated'));
|
||||
} else {
|
||||
const created = await createWikiArticle(payload);
|
||||
setArticle(created);
|
||||
setSelectedArticleId(created.id);
|
||||
toast.success(t('knowledge.editor.created'));
|
||||
}
|
||||
setEditorOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('knowledge.editor.saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editingId, t, toast]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteWikiArticle(deleteTarget.id);
|
||||
toast.success(t('knowledge.editor.deleted'));
|
||||
setArticle(null);
|
||||
setSelectedArticleId(null);
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('knowledge.editor.deleteError'));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [deleteTarget, t, toast]);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!article) return;
|
||||
try {
|
||||
const data = await fetchWikiVersions(article.id);
|
||||
setVersions(data);
|
||||
setShowVersions(true);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('knowledge.versions.loadError'));
|
||||
}
|
||||
}, [article, t, toast]);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (version: number) => {
|
||||
if (!article) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
const restored = await restoreWikiVersion(article.id, version);
|
||||
setArticle(restored);
|
||||
setShowVersions(false);
|
||||
toast.success(t('knowledge.versions.restored'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('knowledge.versions.restoreError'));
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
},
|
||||
[article, t, toast]
|
||||
);
|
||||
|
||||
const statusBadge = useMemo(() => {
|
||||
if (!article) return null;
|
||||
const variant =
|
||||
article.status === 'published' ? 'success' : article.status === 'archived' ? 'secondary' : 'warning';
|
||||
return <Badge variant={variant}>{t(`wiki.status.${article.status}`)}</Badge>;
|
||||
}, [article, t]);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 h-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900">{t('wiki.title')}</h1>
|
||||
<p className="text-sm text-secondary-500 mt-1">{t('wiki.subtitle')}</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" aria-hidden="true" />}>
|
||||
{t('wiki.newArticle')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 h-[calc(100vh-8rem)]">
|
||||
{/* Browser sidebar */}
|
||||
<div className="lg:col-span-1 border border-secondary-200 rounded-lg bg-white overflow-hidden">
|
||||
<WikiBrowser selectedArticleId={selectedArticleId} onSelectArticle={handleSelectArticle} />
|
||||
</div>
|
||||
|
||||
{/* Article detail / editor */}
|
||||
<div className="lg:col-span-3 overflow-y-auto">
|
||||
{loadingArticle ? (
|
||||
<div className="flex items-center justify-center py-20" role="status" aria-label={t('common.loading')}>
|
||||
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
) : article ? (
|
||||
<Card
|
||||
title={article.title}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{statusBadge}
|
||||
<Button variant="ghost" size="sm" onClick={() => void loadVersions()} icon={<History className="h-4 w-4" aria-hidden="true" />}>
|
||||
{t('knowledge.versions.title')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={openEdit} icon={<Pencil className="h-4 w-4" aria-hidden="true" />}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(article)} icon={<Trash2 className="h-4 w-4" aria-hidden="true" />}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-secondary-500 mb-4">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CalendarDays className="h-4 w-4" aria-hidden="true" />
|
||||
{formatDate(article.updated_at)}
|
||||
</span>
|
||||
<span>{t('knowledge.detail.version')}: {article.version}</span>
|
||||
{article.tags.map((tag) => (
|
||||
<span key={tag} className="text-xs text-secondary-400">#{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showVersions && (
|
||||
<div className="mb-4 border border-secondary-200 rounded-lg p-3 bg-secondary-50">
|
||||
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.versions.title')}</h4>
|
||||
{versions.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('knowledge.versions.empty')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{versions.map((v) => (
|
||||
<li key={v.id} className="flex items-center justify-between gap-2 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-secondary-800">v{v.version}</span>
|
||||
<span className="text-secondary-400 ml-2">{formatDate(v.created_at)}</span>
|
||||
{v.edit_comment && <span className="text-secondary-500 ml-2">— {v.edit_comment}</span>}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void handleRestore(v.version)}
|
||||
isLoading={restoring}
|
||||
icon={<Undo2 className="h-4 w-4" aria-hidden="true" />}
|
||||
>
|
||||
{t('knowledge.versions.restore')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{article.content || t('wiki.detail.emptyContent')}</ReactMarkdown>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t('wiki.detail.noSelection')}
|
||||
description={t('wiki.detail.noSelectionDescription')}
|
||||
action={
|
||||
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" aria-hidden="true" />}>
|
||||
{t('wiki.newArticle')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit modal */}
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
title={editingId ? t('wiki.editor.editTitle') : t('wiki.editor.createTitle')}
|
||||
size="xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('wiki.editor.title')}
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t('wiki.editor.slug')}
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||
placeholder={t('wiki.editor.slugPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Select
|
||||
label={t('wiki.editor.category')}
|
||||
value={form.category_id}
|
||||
onChange={(e) => setForm((f) => ({ ...f, category_id: e.target.value }))}
|
||||
options={[
|
||||
{ value: '', label: t('wiki.editor.noCategory') },
|
||||
...categories.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label={t('wiki.editor.status')}
|
||||
value={form.status}
|
||||
onChange={(e) => setForm((f) => ({ ...f, status: e.target.value as WikiArticleStatus }))}
|
||||
options={[
|
||||
{ value: 'draft', label: t('wiki.status.draft') },
|
||||
{ value: 'published', label: t('wiki.status.published') },
|
||||
{ value: 'archived', label: t('wiki.status.archived') },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label={t('wiki.editor.tags')}
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
|
||||
placeholder={t('wiki.editor.tagsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={t('wiki.editor.summary')}
|
||||
value={form.summary}
|
||||
onChange={(e) => setForm((f) => ({ ...f, summary: e.target.value }))}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('wiki.editor.content')}</label>
|
||||
<WikiEditor
|
||||
value={form.content}
|
||||
onChange={(value) => setForm((f) => ({ ...f, content: value }))}
|
||||
placeholder={t('wiki.editor.contentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setEditorOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void handleSave()} isLoading={saving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
title={t('wiki.delete.title')}
|
||||
message={t('wiki.delete.message', { title: deleteTarget?.title ?? '' })}
|
||||
confirmLabel={t('common.delete')}
|
||||
variant="danger"
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user