refactor: replace DashboardPage and DashboardTableShell with DashboardListPage for notifications and permissions

- Updated DashboardNotificationsPage to utilize DashboardListPage for improved structure and functionality.
- Simplified state management and data fetching in DashboardNotificationsPage.
- Refactored DashboardPermissionsPage to use DashboardListPage, enhancing filter and pagination handling.
- Introduced DashboardListPage component to standardize list rendering with filters and pagination.
- Added utility functions for managing filters and pagination in the new DashboardListPage component.
- Improved code readability and maintainability by consolidating common logic into reusable components.
This commit is contained in:
mlogclub
2026-05-28 09:36:48 +08:00
parent b3f5824fd6
commit d64b0c36f3
10 changed files with 815 additions and 679 deletions
@@ -1,13 +1,11 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import { useMemo, useState } from "react"
import { SearchIcon } from "lucide-react"
import {
fetchKnowledgeRetrieveLogs,
type KnowledgeRetrieveLog,
type PageResult,
} from "@/lib/api/admin"
import {
KnowledgeAnswerStatus,
@@ -22,19 +20,8 @@ import {
} from "@/lib/knowledge-i18n"
import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { DashboardListPage } from "@/components/dashboard/list"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { RetrieveLogDetailDrawer } from "./retrieve-log-detail"
type RetrieveLogListProps = {
@@ -125,158 +112,108 @@ export function RetrieveLogList({
knowledgeBaseId,
}: RetrieveLogListProps) {
const t = useI18n()
const [questionInput, setQuestionInput] = useState("")
const [question, setQuestion] = useState("")
const [channel, setChannel] = useState("all")
const [scene, setScene] = useState("all")
const [answerStatus, setAnswerStatus] = useState("all")
const [chunkProvider, setChunkProvider] = useState("all")
const [rerankEnabled, setRerankEnabled] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [selectedLogId, setSelectedLogId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<KnowledgeRetrieveLog>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState<number | null>(null)
const channelOptions = useMemo(() => getChannelOptions(t), [t])
const sceneOptions = useMemo(() => getSceneOptions(t), [t])
const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t])
const providerOptions = useMemo(() => getProviderOptions(t), [t])
const rerankOptions = useMemo(() => getRerankOptions(t), [t])
const loadData = useCallback(async () => {
if (!knowledgeBaseId) {
setResult({ results: [], page: { page: 1, limit: 20, total: 0 } })
setLoading(false)
return
}
setLoading(true)
try {
const data = await fetchKnowledgeRetrieveLogs({
knowledgeBaseId,
question: question.trim() || undefined,
channel: channel === "all" ? undefined : channel,
scene: scene === "all" ? undefined : scene,
answerStatus: answerStatus === "all" ? undefined : Number(answerStatus),
chunkProvider: chunkProvider === "all" ? undefined : chunkProvider,
rerankEnabled: rerankEnabled === "all" ? undefined : Number(rerankEnabled),
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogsFailed"))
} finally {
setLoading(false)
}
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene, t])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
setPage(1)
setSelectedLogId(null)
setDetailOpen(false)
}, [knowledgeBaseId])
const emptyStateText = useMemo(() => {
if (!knowledgeBaseId) {
return t("knowledge.selectBaseForLogs")
}
if (loading) {
return t("knowledge.loadingRetrieveLogs")
}
return t("knowledge.emptyRetrieveLogs")
}, [knowledgeBaseId, loading, t])
function applyFilters() {
setQuestion(questionInput)
setPage(1)
}
function handleQuestionKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
function handleOpenDetail(logId: number) {
setSelectedLogId(logId)
setSelectedKnowledgeBaseId(knowledgeBaseId)
setDetailOpen(true)
}
if (!knowledgeBaseId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{emptyStateText}
{t("knowledge.selectBaseForLogs")}
</div>
)
}
return (
<>
<div className="flex h-full flex-col">
<div className="flex flex-col gap-3 border-b bg-background px-6 py-2">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.8fr)_repeat(5,minmax(0,0.8fr))_auto]">
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={questionInput}
onChange={(event) => setQuestionInput(event.target.value)}
onKeyDown={handleQuestionKeyDown}
placeholder={t("knowledge.filterQuestion")}
className="pl-9"
/>
</div>
<OptionCombobox value={channel} options={channelOptions} placeholder={t("knowledge.selectChannel")} onChange={setChannel} />
<OptionCombobox value={scene} options={sceneOptions} placeholder={t("knowledge.selectScene")} onChange={setScene} />
<OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder={t("knowledge.answerStatus")} onChange={setAnswerStatus} />
<OptionCombobox value={chunkProvider} options={providerOptions} placeholder={t("knowledge.chunkStrategy")} onChange={setChunkProvider} />
<OptionCombobox value={rerankEnabled} options={rerankOptions} placeholder="Rerank" onChange={setRerankEnabled} />
<Button onClick={applyFilters}>{t("knowledge.filter")}</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-6 py-4">
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-42">{t("knowledge.time")}</TableHead>
<TableHead>{t("knowledge.question")}</TableHead>
<TableHead className="w-28">{t("knowledge.answerStatus")}</TableHead>
<TableHead className="w-24 text-right">{t("knowledge.hitCount")}</TableHead>
<TableHead className="w-24 text-right">TopScore</TableHead>
<TableHead className="w-28">Provider</TableHead>
<TableHead className="w-24">Rerank</TableHead>
<TableHead className="w-24 text-right">{t("knowledge.citations")}</TableHead>
<TableHead className="w-28 text-right">{t("knowledge.duration")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-32 text-center text-muted-foreground">
{emptyStateText}
</TableCell>
</TableRow>
) : (
result.results.map((item) => (
<TableRow
key={item.id}
className="cursor-pointer"
onClick={() => handleOpenDetail(item.id)}
>
<TableCell className="text-xs text-muted-foreground">{formatDateTime(item.createdAt)}</TableCell>
<TableCell>
<div className="flex h-full flex-col px-6 py-4">
<DashboardListPage<KnowledgeRetrieveLog>
layout="fragment"
filters={[
{
name: "question",
label: t("knowledge.filterQuestion"),
placeholder: t("knowledge.filterQuestion"),
defaultValue: "",
trim: true,
className: "min-w-0 xl:min-w-[280px]",
inputClassName: "pl-9",
icon: <SearchIcon className="size-4" />,
},
{
name: "channel",
label: t("knowledge.selectChannel"),
type: "select",
defaultValue: "all",
allValue: "all",
options: channelOptions,
placeholder: t("knowledge.selectChannel"),
},
{
name: "scene",
label: t("knowledge.selectScene"),
type: "select",
defaultValue: "all",
allValue: "all",
options: sceneOptions,
placeholder: t("knowledge.selectScene"),
},
{
name: "answerStatus",
label: t("knowledge.answerStatus"),
type: "select",
defaultValue: "all",
allValue: "all",
valueType: "number",
options: answerStatusOptions,
placeholder: t("knowledge.answerStatus"),
},
{
name: "chunkProvider",
label: t("knowledge.chunkStrategy"),
type: "select",
defaultValue: "all",
allValue: "all",
options: providerOptions,
placeholder: t("knowledge.chunkStrategy"),
},
{
name: "rerankEnabled",
label: "Rerank",
type: "select",
defaultValue: "all",
allValue: "all",
valueType: "number",
options: rerankOptions,
placeholder: "Rerank",
},
]}
fetchList={(query) => fetchKnowledgeRetrieveLogs({ knowledgeBaseId, ...query })}
getItemId={(item) => item.id}
getRowClassName={() => "cursor-pointer"}
onRowClick={(item) => handleOpenDetail(item.id)}
columns={[
{
key: "time",
label: t("knowledge.time"),
className: "w-42 text-xs text-muted-foreground",
render: (item) => formatDateTime(item.createdAt),
},
{
key: "question",
label: t("knowledge.question"),
render: (item) => (
<div className="space-y-1">
<div className="line-clamp-2 font-medium">{item.question || "-"}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@@ -285,45 +222,78 @@ export function RetrieveLogList({
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
</div>
</div>
</TableCell>
<TableCell>
),
},
{
key: "answerStatus",
label: t("knowledge.answerStatus"),
className: "w-28",
render: (item) => (
<Badge variant={getAnswerStatusVariant(item.answerStatus)}>
{answerStatusLabel(item.answerStatus, item.answerStatusName, t)}
</Badge>
</TableCell>
<TableCell className="text-right">{item.hitCount}</TableCell>
<TableCell className="text-right font-mono text-xs">{item.topScore.toFixed(4)}</TableCell>
<TableCell>{item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-"}</TableCell>
<TableCell>{item.rerankEnabled ? `${t("knowledge.yes")} (${item.rerankLimit})` : t("knowledge.no")}</TableCell>
<TableCell className="text-right">{item.citationCount}</TableCell>
<TableCell className="text-right">{item.latencyMs} ms</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<div className="border-t px-6 py-4">
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading}
onPageChange={setPage}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
),
},
{
key: "hitCount",
label: t("knowledge.hitCount"),
className: "w-24 text-right",
render: (item) => item.hitCount,
},
{
key: "topScore",
label: "TopScore",
className: "w-24 text-right font-mono text-xs",
render: (item) => item.topScore.toFixed(4),
},
{
key: "provider",
label: "Provider",
className: "w-28",
render: (item) =>
item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-",
},
{
key: "rerank",
label: "Rerank",
className: "w-24",
render: (item) =>
item.rerankEnabled
? `${t("knowledge.yes")} (${item.rerankLimit})`
: t("knowledge.no"),
},
{
key: "citations",
label: t("knowledge.citations"),
className: "w-24 text-right",
render: (item) => item.citationCount,
},
{
key: "duration",
label: t("knowledge.duration"),
className: "w-28 text-right",
render: (item) => `${item.latencyMs} ms`,
},
]}
labels={{
query: t("knowledge.filter"),
loading: t("knowledge.loadingRetrieveLogs"),
empty: t("knowledge.emptyRetrieveLogs"),
loadFailed: t("knowledge.loadRetrieveLogsFailed"),
}}
/>
</div>
<RetrieveLogDetailDrawer
open={detailOpen}
open={detailOpen && selectedKnowledgeBaseId === knowledgeBaseId}
retrieveLogId={selectedLogId}
onOpenChange={setDetailOpen}
onOpenChange={(open) => {
setDetailOpen(open)
if (!open) {
setSelectedLogId(null)
setSelectedKnowledgeBaseId(null)
}
}}
/>
</>
)