Files
teatea-pension/modules/ai/components/KnowledgeManagementClient.tsx

355 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { FormEvent, useMemo, useState } from "react";
import { Edit3, Plus, Search, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { AiKnowledgeScope, AiKnowledgeStatus, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
import type { ApiResult } from "@/modules/core/server/api";
import type { Permission } from "@/modules/core/types";
type KnowledgeManagementClientProps = {
initialEntries: KnowledgeEntry[];
permissions: Permission[];
};
type KnowledgeFormState = {
scope: AiKnowledgeScope;
title: string;
category: string;
tags: string;
body: string;
status: AiKnowledgeStatus;
};
const emptyForm: KnowledgeFormState = {
scope: "organization",
title: "",
category: "",
tags: "",
body: "",
status: "enabled",
};
const scopeOptions = [
{ value: "organization", label: "机构私有" },
{ value: "platform", label: "平台共享" },
];
const statusOptions = [
{ value: "enabled", label: "启用" },
{ value: "disabled", label: "停用" },
];
function entryToForm(entry: KnowledgeEntry): KnowledgeFormState {
return {
scope: entry.scope,
title: entry.title,
category: entry.category,
tags: entry.tags,
body: entry.body,
status: entry.status,
};
}
function matchesEntry(entry: KnowledgeEntry, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return true;
}
return [entry.title, entry.category, entry.tags, entry.body].join(" ").toLowerCase().includes(normalized);
}
export function KnowledgeManagementClient({ initialEntries, permissions }: KnowledgeManagementClientProps): React.ReactElement {
const [entries, setEntries] = useState(initialEntries);
const [query, setQuery] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<KnowledgeEntry | null>(null);
const [form, setForm] = useState<KnowledgeFormState>(emptyForm);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [message, setMessage] = useState("");
const canManage = permissions.includes("knowledge:manage");
const filteredEntries = useMemo(() => entries.filter((entry) => matchesEntry(entry, query)), [entries, query]);
const isEditing = editingId !== null;
function updateField<K extends keyof KnowledgeFormState>(key: K, value: KnowledgeFormState[K]): void {
setForm((current) => ({ ...current, [key]: value }));
}
function beginCreate(): void {
setEditingId(null);
setForm(emptyForm);
setMessage("");
setIsDialogOpen(true);
}
function beginEdit(entry: KnowledgeEntry): void {
setEditingId(entry.id);
setForm(entryToForm(entry));
setMessage("");
setIsDialogOpen(true);
}
function closeDialog(): void {
if (isPending) {
return;
}
setIsDialogOpen(false);
setEditingId(null);
setForm(emptyForm);
}
async function refreshEntries(): Promise<void> {
const response = await fetch("/api/ai/knowledge");
const result = (await response.json()) as ApiResult<{ entries: KnowledgeEntry[] }>;
if (result.success) {
setEntries(result.entries);
}
}
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManage) {
setMessage("当前角色无权维护知识库");
return;
}
setIsPending(true);
setMessage("");
const payload: KnowledgeEntryInput = {
...form,
};
const response = await fetch(isEditing ? `/api/ai/knowledge/${editingId}` : "/api/ai/knowledge", {
method: isEditing ? "PATCH" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
closeDialog();
setMessage(result.reason);
}
async function remove(): Promise<void> {
if (!deleteTarget || !canManage) {
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/ai/knowledge/${deleteTarget.id}`, { method: "DELETE" });
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
setDeleteTarget(null);
setMessage(result.reason);
}
async function toggleStatus(entry: KnowledgeEntry): Promise<void> {
const nextStatus: AiKnowledgeStatus = entry.status === "enabled" ? "disabled" : "enabled";
const response = await fetch(`/api/ai/knowledge/${entry.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...entry, status: nextStatus }),
});
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshEntries();
setMessage(result.reason);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<Button disabled={!canManage} onClick={beginCreate} type="button">
<Plus className="size-4" aria-hidden="true" />
</Button>
</section>
<Card>
<CardHeader className="gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription> {filteredEntries.length} </CardDescription>
</div>
<label className="relative block w-full md:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input aria-label="搜索知识条目" className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
</label>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[860px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredEntries.map((entry) => (
<Table.Row key={entry.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{entry.title}</p>
<p className="max-w-md truncate text-xs text-muted-foreground">{entry.body}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3">{entry.scope === "platform" ? "平台共享" : "机构私有"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.category || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.tags || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={entry.status === "enabled" ? "success" : "secondary"}>
{entry.status === "enabled" ? "启用" : "停用"}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button disabled={!canManage} onClick={() => void toggleStatus(entry)} size="sm" type="button" variant="outline">
{entry.status === "enabled" ? "停用" : "启用"}
</Button>
<Button disabled={!canManage} onClick={() => beginEdit(entry)} size="sm" type="button" variant="outline">
<Edit3 className="size-4" aria-hidden="true" />
</Button>
<Button disabled={!canManage} onClick={() => setDeleteTarget(entry)} size="sm" type="button" variant="destructive">
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
{filteredEntries.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</CardContent>
</Card>
<Dialog
description="保存后将纳入智能分析的知识参考范围。"
onClose={closeDialog}
open={isDialogOpen}
title={isEditing ? "编辑知识" : "新增知识"}
width="lg"
>
<form className="grid gap-3" onSubmit={submit}>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="知识范围"
onValueChange={(value) => updateField("scope", value as AiKnowledgeScope)}
options={scopeOptions}
value={form.scope}
/>
</div>
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="知识状态"
onValueChange={(value) => updateField("status", value as AiKnowledgeStatus)}
options={statusOptions}
value={form.status}
/>
</div>
</div>
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("title", event.target.value)} required value={form.title} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("category", event.target.value)} value={form.category} />
</label>
<label className="grid gap-1.5 text-sm font-medium">
<Input onChange={(event) => updateField("tags", event.target.value)} placeholder="逗号分隔" value={form.tags} />
</label>
</div>
<label className="grid gap-1.5 text-sm font-medium">
<Textarea className="min-h-56" onChange={(event) => updateField("body", event.target.value)} required value={form.body} />
</label>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={closeDialog} type="button" variant="outline">
</Button>
<Button disabled={isPending || !canManage} type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
</Dialog>
<Dialog
description={deleteTarget ? `确认删除「${deleteTarget.title}」?对应分块也会删除。` : undefined}
onClose={() => setDeleteTarget(null)}
open={deleteTarget !== null}
title="删除确认"
width="sm"
>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={() => setDeleteTarget(null)} type="button" variant="outline">
</Button>
<Button disabled={isPending || !canManage} onClick={() => void remove()} type="button" variant="destructive">
</Button>
</div>
</Dialog>
</div>
);
}