feat: add AI knowledge analysis MVP
This commit is contained in:
226
modules/ai/components/ElderAiAnalysisDialog.tsx
Normal file
226
modules/ai/components/ElderAiAnalysisDialog.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Brain, RefreshCw, Sparkles } 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 type { ElderAiAnalysisHistoryItem } from "@/modules/ai/types";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import type { Elder } from "@/modules/elders/types";
|
||||
|
||||
type ElderAiAnalysisDialogProps = {
|
||||
elder: Elder | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
low: "低",
|
||||
medium: "中",
|
||||
high: "高",
|
||||
critical: "紧急",
|
||||
unknown: "未知",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisDialogProps): React.ReactElement {
|
||||
const [history, setHistory] = useState<ElderAiAnalysisHistoryItem[]>([]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
async function loadHistory(target: Elder): Promise<void> {
|
||||
setIsLoading(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/ai/elders/${target.id}/analyses`);
|
||||
const result = (await response.json()) as ApiResult<{ history: ElderAiAnalysisHistoryItem[] }>;
|
||||
setIsLoading(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
setHistory(result.history);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && elder) {
|
||||
void loadHistory(elder);
|
||||
}
|
||||
}, [elder, open]);
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
if (!elder) {
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/ai/elders/${elder.id}/analyses`, { method: "POST" });
|
||||
const result = (await response.json()) as ApiResult<{ analysis: ElderAiAnalysisHistoryItem }>;
|
||||
setIsGenerating(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
await loadHistory(elder);
|
||||
return;
|
||||
}
|
||||
setHistory((current) => [result.analysis, ...current]);
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
const latest = history[0];
|
||||
const latestResult = latest && !latest.restricted ? latest.result : undefined;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
title="AI 分析"
|
||||
width="wide"
|
||||
>
|
||||
{elder ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-secondary/30 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-semibold">{elder.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{elder.age} 岁 · {elder.careLevel} · {elder.status} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={isLoading || isGenerating} onClick={() => void loadHistory(elder)} type="button" variant="outline">
|
||||
<RefreshCw className="size-4" aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
{isGenerating ? "生成中" : "生成分析"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{latestResult ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>最新分析</CardTitle>
|
||||
<CardDescription>{latest ? formatDateTime(latest.createdAt) : ""}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={latestResult.overallRiskLevel === "critical" || latestResult.overallRiskLevel === "high" ? "destructive" : "secondary"}>
|
||||
风险:{riskLabels[latestResult.overallRiskLevel] ?? latestResult.overallRiskLevel}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<p className="text-sm leading-6">{latestResult.summary}</p>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">关键发现</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.keyFindings.map((finding, index) => (
|
||||
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{finding.category}</span>
|
||||
<Badge variant={finding.severity === "critical" ? "destructive" : "secondary"}>{finding.severity}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">建议动作</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.recommendations.map((recommendation, index) => (
|
||||
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{recommendation.title}</span>
|
||||
<Badge variant={recommendation.priority === "urgent" ? "destructive" : "secondary"}>{recommendation.priority}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
|
||||
<p className="mt-1">{recommendation.suggestedNextStep}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{latestResult.dataGaps.length > 0 ? (
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">数据缺口</h3>
|
||||
<ul className="grid gap-1 text-sm text-muted-foreground">
|
||||
{latestResult.dataGaps.map((gap) => (
|
||||
<li key={gap}>{gap}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">引用来源</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.citations.map((citation) => (
|
||||
<div className="rounded-md bg-secondary/40 p-3 text-xs" key={citation.id}>
|
||||
<p className="font-medium">
|
||||
[{citation.id}] {citation.title}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{citation.excerpt}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : latest ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最新记录</CardTitle>
|
||||
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">历史记录</h3>
|
||||
<div className="grid gap-2">
|
||||
{history.map((item) => (
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
|
||||
<div>
|
||||
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
|
||||
<p className="text-xs text-muted-foreground">范围:{item.dataScopes.join(", ") || "-"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.restricted ? <Badge variant="secondary">受限</Badge> : null}
|
||||
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{item.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
<Brain className="mr-2 size-4" aria-hidden="true" />
|
||||
未选择老人档案
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
353
modules/ai/components/KnowledgeManagementClient.tsx
Normal file
353
modules/ai/components/KnowledgeManagementClient.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"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 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="保存时会写入数据库、重建分块和 embedding。"
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user