Files
new-api/web/default/src/features/docs-management/index.tsx
T
2026-06-14 21:20:47 +08:00

290 lines
12 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
import { Plus, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
import { api } from '@/lib/api'
interface Category {
id: number
name: string
slug: string
parent_id: number | null
sort_order: number
}
interface Doc {
id: number
title: string
slug: string
content: string
category_id: number | null
visibility: string
sort_order: number
author_id: number
created_at: string
}
export function DocsManagement() {
const { t } = useTranslation()
const [docs, setDocs] = useState<Doc[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingDoc, setEditingDoc] = useState<Doc | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Doc | null>(null)
const [form, setForm] = useState({
title: '',
slug: '',
content: '',
category_id: '',
visibility: 'public',
sort_order: 0,
})
const fetchData = useCallback(async () => {
try {
setLoading(true)
const [docsRes, catsRes] = await Promise.all([
api.get('/api/docs/'),
api.get('/api/docs/categories'),
])
setDocs(docsRes.data?.data || [])
setCategories(catsRes.data?.data || [])
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const openCreate = () => {
setEditingDoc(null)
setForm({ title: '', slug: '', content: '', category_id: '', visibility: 'public', sort_order: 0 })
setDialogOpen(true)
}
const openEdit = (doc: Doc) => {
setEditingDoc(doc)
setForm({
title: doc.title,
slug: doc.slug,
content: doc.content || '',
category_id: doc.category_id?.toString() || '',
visibility: doc.visibility,
sort_order: doc.sort_order,
})
setDialogOpen(true)
}
const handleSubmit = async () => {
try {
const payload = {
title: form.title,
slug: form.slug || form.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\u4e00-\u9fff-]/g, ''),
content: form.content,
category_id: form.category_id ? parseInt(form.category_id) : null,
visibility: form.visibility,
sort_order: form.sort_order,
}
if (editingDoc) {
await api.put(`/api/docs/admin/${editingDoc.id}`, payload)
} else {
await api.post('/api/docs/admin/', payload)
}
setDialogOpen(false)
fetchData()
} catch (err) {
console.error('Failed to save document:', err)
}
}
const handleDelete = async () => {
if (!deleteTarget) return
try {
await api.delete(`/api/docs/admin/${deleteTarget.id}`)
setDeleteTarget(null)
fetchData()
} catch (err) {
console.error('Failed to delete document:', err)
}
}
const handleTitleChange = (title: string) => {
setForm(prev => ({
...prev,
title,
slug: title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\u4e00-\u9fff-]/g, ''),
}))
}
const getCategoryName = (categoryId: number | null) => {
if (!categoryId) return '-'
return categories.find(c => c.id === categoryId)?.name || categoryId.toString()
}
const visibilityLabels: Record<string, string> = {
public: t('Public'),
auth: t('Authenticated'),
admin: t('Admin Only'),
}
return (
<SectionPageLayout>
<SectionPageLayout.Title>{t('Document Management')}</SectionPageLayout.Title>
<SectionPageLayout.Actions>
<Button onClick={openCreate} size='sm'>
<Plus className='mr-1 size-4' />
{t('Add Document')}
</Button>
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('Title')}</TableHead>
<TableHead>{t('Category')}</TableHead>
<TableHead>{t('Visibility')}</TableHead>
<TableHead>{t('Sort Order')}</TableHead>
<TableHead className='w-[80px]'>{t('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={5} className='text-center text-muted-foreground py-8'>{t('Loading...')}</TableCell></TableRow>
) : docs.length === 0 ? (
<TableRow><TableCell colSpan={5} className='text-center text-muted-foreground py-8'>{t('No documents found')}</TableCell></TableRow>
) : (
docs.map(doc => (
<TableRow key={doc.id}>
<TableCell className='font-medium'>{doc.title}</TableCell>
<TableCell className='text-muted-foreground'>{getCategoryName(doc.category_id)}</TableCell>
<TableCell>
<span className='inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-primary/10 text-primary'>
{visibilityLabels[doc.visibility] || doc.visibility}
</span>
</TableCell>
<TableCell>{doc.sort_order}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant='ghost' size='icon' className='size-8'><MoreHorizontal className='size-4' /></Button></DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => openEdit(doc)}><Pencil className='mr-2 size-4' />{t('Edit')}</DropdownMenuItem>
<DropdownMenuItem className='text-destructive' onClick={() => setDeleteTarget(doc)}><Trash2 className='mr-2 size-4' />{t('Delete')}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</SectionPageLayout.Content>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className='max-w-2xl max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>{editingDoc ? t('Edit Document') : t('Add Document')}</DialogTitle>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-2'>
<label className='text-sm font-medium'>{t('Title')}</label>
<Input value={form.title} onChange={e => handleTitleChange(e.target.value)} placeholder={t('Document title')} />
</div>
<div className='space-y-2'>
<label className='text-sm font-medium'>{t('Slug')}</label>
<Input value={form.slug} onChange={e => setForm(prev => ({ ...prev, slug: e.target.value }))} placeholder='document-slug' />
</div>
</div>
<div className='grid grid-cols-3 gap-4'>
<div className='space-y-2'>
<label className='text-sm font-medium'>{t('Category')}</label>
<select
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors'
value={form.category_id}
onChange={e => setForm(prev => ({ ...prev, category_id: e.target.value }))}
>
<option value=''>{t('None')}</option>
{categories.map(c => (
<option key={c.id} value={c.id.toString()}>{c.name}</option>
))}
</select>
</div>
<div className='space-y-2'>
<label className='text-sm font-medium'>{t('Visibility')}</label>
<select
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors'
value={form.visibility}
onChange={e => setForm(prev => ({ ...prev, visibility: e.target.value }))}
>
<option value='public'>{t('Public')}</option>
<option value='auth'>{t('Authenticated')}</option>
<option value='admin'>{t('Admin Only')}</option>
</select>
</div>
<div className='space-y-2'>
<label className='text-sm font-medium'>{t('Sort Order')}</label>
<Input type='number' value={form.sort_order} onChange={e => setForm(prev => ({ ...prev, sort_order: parseInt(e.target.value) || 0 }))} />
</div>
</div>
<div className='space-y-2'>
<label className='text-sm font-medium'>{t('Content')} (Markdown)</label>
<textarea
className='flex min-h-[300px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors font-mono'
value={form.content}
onChange={e => setForm(prev => ({ ...prev, content: e.target.value }))}
placeholder='# Document Title&#10;&#10;Write your content here...'
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setDialogOpen(false)}>{t('Cancel')}</Button>
<Button onClick={handleSubmit} disabled={!form.title}>{t('Save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Delete Document')}</AlertDialogTitle>
<AlertDialogDescription>{t('Are you sure you want to delete this document? This action cannot be undone.')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className='bg-destructive text-destructive-foreground hover:bg-destructive/90'>{t('Delete')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SectionPageLayout>
)
}