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:
@@ -1,26 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { RefreshCwIcon, SearchIcon } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page"
|
||||
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 { AgentRunLogDetailDialog } from "./_components/detail"
|
||||
import {
|
||||
fetchAgentRunLogs,
|
||||
fetchAIAgentsAll,
|
||||
type AIAgent,
|
||||
type AgentRunLog,
|
||||
type PageResult,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
@@ -100,27 +92,8 @@ function actionBadgeVariant(action: string) {
|
||||
|
||||
export default function DashboardAgentRunLogsPage() {
|
||||
const t = useI18n()
|
||||
const [keywordInput, setKeywordInput] = useState("")
|
||||
const [plannedActionInput, setPlannedActionInput] = useState("all")
|
||||
const [finalActionInput, setFinalActionInput] = useState("all")
|
||||
const [finalStatusInput, setFinalStatusInput] = useState("all")
|
||||
const [hitlStatusInput, setHitlStatusInput] = useState("all")
|
||||
const [aiAgentIdInput, setAiAgentIdInput] = useState("all")
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [plannedAction, setPlannedAction] = useState("all")
|
||||
const [finalAction, setFinalAction] = useState("all")
|
||||
const [finalStatus, setFinalStatus] = useState("all")
|
||||
const [hitlStatus, setHitlStatus] = useState("all")
|
||||
const [aiAgentId, setAiAgentId] = useState("all")
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [activeLogId, setActiveLogId] = useState<number | null>(null)
|
||||
const [result, setResult] = useState<PageResult<AgentRunLog>>({
|
||||
results: [],
|
||||
page: { page: 1, limit: 20, total: 0 },
|
||||
})
|
||||
const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
|
||||
const actionOptions = useMemo(() => getActionOptions(t), [t])
|
||||
const finalStatusOptions = useMemo(() => getFinalStatusOptions(t), [t])
|
||||
@@ -137,31 +110,6 @@ export default function DashboardAgentRunLogsPage() {
|
||||
[aiAgents, t]
|
||||
)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchAgentRunLogs({
|
||||
userMessage: keyword.trim() || undefined,
|
||||
plannedAction: plannedAction === "all" ? undefined : plannedAction,
|
||||
finalAction: finalAction === "all" ? undefined : finalAction,
|
||||
finalStatus: finalStatus === "all" ? undefined : finalStatus,
|
||||
hitlStatus: hitlStatus === "all" ? undefined : hitlStatus,
|
||||
aiAgentId: aiAgentId === "all" ? undefined : aiAgentId,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
setResult(data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentRunLog.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAIAgents() {
|
||||
try {
|
||||
@@ -174,122 +122,84 @@ export default function DashboardAgentRunLogsPage() {
|
||||
void loadAIAgents()
|
||||
}, [t])
|
||||
|
||||
function applyFilters() {
|
||||
setKeyword(keywordInput)
|
||||
setPlannedAction(plannedActionInput)
|
||||
setFinalAction(finalActionInput)
|
||||
setFinalStatus(finalStatusInput)
|
||||
setHitlStatus(hitlStatusInput)
|
||||
setAiAgentId(aiAgentIdInput)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPage>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void loadData()}
|
||||
disabled={loading}
|
||||
className="w-full xl:w-auto"
|
||||
>
|
||||
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
|
||||
{t("agentRunLog.refresh")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="relative min-w-0">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={t("agentRunLog.filterUserMessage")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<OptionCombobox
|
||||
value={plannedActionInput}
|
||||
options={actionOptions}
|
||||
placeholder={t("agentRunLog.plannedAction")}
|
||||
searchPlaceholder={t("agentRunLog.searchAction")}
|
||||
emptyText={t("agentRunLog.emptyAction")}
|
||||
onChange={(value) => setPlannedActionInput(value || "all")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<OptionCombobox
|
||||
value={finalActionInput}
|
||||
options={actionOptions}
|
||||
placeholder={t("agentRunLog.finalAction")}
|
||||
searchPlaceholder={t("agentRunLog.searchAction")}
|
||||
emptyText={t("agentRunLog.emptyAction")}
|
||||
onChange={(value) => setFinalActionInput(value || "all")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<OptionCombobox
|
||||
value={finalStatusInput}
|
||||
options={finalStatusOptions}
|
||||
placeholder={t("agentRunLog.finalStatus")}
|
||||
searchPlaceholder={t("agentRunLog.searchStatus")}
|
||||
emptyText={t("agentRunLog.emptyStatus")}
|
||||
onChange={(value) => setFinalStatusInput(value || "all")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<OptionCombobox
|
||||
value={hitlStatusInput}
|
||||
options={hitlStatusOptions}
|
||||
placeholder={t("agentRunLog.hitlStatus")}
|
||||
searchPlaceholder={t("agentRunLog.searchHitl")}
|
||||
emptyText={t("agentRunLog.emptyStatus")}
|
||||
onChange={(value) => setHitlStatusInput(value || "all")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<OptionCombobox
|
||||
value={aiAgentIdInput}
|
||||
options={aiAgentOptions}
|
||||
placeholder={t("agentRunLog.selectAgent")}
|
||||
searchPlaceholder={t("agentRunLog.searchAgent")}
|
||||
emptyText={t("agentRunLog.emptyAgent")}
|
||||
onChange={(value) => setAiAgentIdInput(value || "all")}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={loading} className="w-full xl:w-auto">
|
||||
<SearchIcon />
|
||||
{t("agentRunLog.query")}
|
||||
</Button>
|
||||
</DashboardToolbar>
|
||||
|
||||
<DashboardTableShell
|
||||
pagination={
|
||||
<ListPagination
|
||||
page={result.page.page}
|
||||
total={result.page.total}
|
||||
limit={limit}
|
||||
loading={loading}
|
||||
onPageChange={setPage}
|
||||
onLimitChange={(nextLimit) => {
|
||||
setLimit(nextLimit)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!loading && result.results.length === 0 ? (
|
||||
<DashboardListPage<AgentRunLog>
|
||||
filters={[
|
||||
{
|
||||
name: "userMessage",
|
||||
label: t("agentRunLog.filterUserMessage"),
|
||||
placeholder: t("agentRunLog.filterUserMessage"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "min-w-0",
|
||||
inputClassName: "pl-9",
|
||||
icon: <SearchIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
name: "plannedAction",
|
||||
label: t("agentRunLog.plannedAction"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: actionOptions,
|
||||
placeholder: t("agentRunLog.plannedAction"),
|
||||
searchPlaceholder: t("agentRunLog.searchAction"),
|
||||
emptyText: t("agentRunLog.emptyAction"),
|
||||
className: "min-w-0",
|
||||
},
|
||||
{
|
||||
name: "finalAction",
|
||||
label: t("agentRunLog.finalAction"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: actionOptions,
|
||||
placeholder: t("agentRunLog.finalAction"),
|
||||
searchPlaceholder: t("agentRunLog.searchAction"),
|
||||
emptyText: t("agentRunLog.emptyAction"),
|
||||
className: "min-w-0",
|
||||
},
|
||||
{
|
||||
name: "finalStatus",
|
||||
label: t("agentRunLog.finalStatus"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: finalStatusOptions,
|
||||
placeholder: t("agentRunLog.finalStatus"),
|
||||
searchPlaceholder: t("agentRunLog.searchStatus"),
|
||||
emptyText: t("agentRunLog.emptyStatus"),
|
||||
className: "min-w-0",
|
||||
},
|
||||
{
|
||||
name: "hitlStatus",
|
||||
label: t("agentRunLog.hitlStatus"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: hitlStatusOptions,
|
||||
placeholder: t("agentRunLog.hitlStatus"),
|
||||
searchPlaceholder: t("agentRunLog.searchHitl"),
|
||||
emptyText: t("agentRunLog.emptyStatus"),
|
||||
className: "min-w-0",
|
||||
},
|
||||
{
|
||||
name: "aiAgentId",
|
||||
label: t("agentRunLog.selectAgent"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: aiAgentOptions,
|
||||
placeholder: t("agentRunLog.selectAgent"),
|
||||
searchPlaceholder: t("agentRunLog.searchAgent"),
|
||||
emptyText: t("agentRunLog.emptyAgent"),
|
||||
className: "min-w-0",
|
||||
},
|
||||
]}
|
||||
fetchList={fetchAgentRunLogs}
|
||||
renderContent={({ result, loading }) =>
|
||||
!loading && result.results.length === 0 ? (
|
||||
<div className="py-14 text-center text-sm text-muted-foreground">
|
||||
{t("agentRunLog.emptyRows")}
|
||||
</div>
|
||||
@@ -388,9 +298,16 @@ export default function DashboardAgentRunLogsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DashboardTableShell>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
labels={{
|
||||
refresh: t("agentRunLog.refresh"),
|
||||
query: t("agentRunLog.query"),
|
||||
loading: t("agentRunLog.loadingRows"),
|
||||
empty: t("agentRunLog.emptyRows"),
|
||||
loadFailed: t("agentRunLog.loadFailed"),
|
||||
}}
|
||||
/>
|
||||
<AgentRunLogDetailDialog
|
||||
open={detailOpen}
|
||||
logId={activeLogId}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
|
||||
import { BellIcon, CheckCheckIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page"
|
||||
import { ListPagination } from "@/components/list-pagination"
|
||||
import { DashboardListPage } from "@/components/dashboard/list"
|
||||
import { useNotifications } from "@/components/notification-provider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -21,51 +15,18 @@ import {
|
||||
type NotificationItem,
|
||||
type NotificationReadStatus,
|
||||
} from "@/lib/api/notification"
|
||||
import type { PageResult } from "@/lib/api/admin"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export default function DashboardNotificationsPage() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const { refreshUnreadCount } = useNotifications()
|
||||
const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all")
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [result, setResult] = useState<PageResult<NotificationItem>>({
|
||||
results: [],
|
||||
page: { page: 1, limit: 20, total: 0 },
|
||||
})
|
||||
const readStatusOptions = useMemo<Array<{ value: NotificationReadStatus; label: string }>>(
|
||||
() => [
|
||||
{ value: "all", label: t("notification.all") },
|
||||
{ value: "unread", label: t("notification.unread") },
|
||||
{ value: "read", label: t("notification.read") },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchNotifications({
|
||||
page,
|
||||
limit,
|
||||
readStatus,
|
||||
})
|
||||
setResult(data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("notification.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [limit, page, readStatus, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
|
||||
{ value: "all", label: t("notification.all") },
|
||||
{ value: "unread", label: t("notification.unread") },
|
||||
{ value: "read", label: t("notification.read") },
|
||||
]
|
||||
|
||||
async function openNotification(item: NotificationItem) {
|
||||
try {
|
||||
@@ -81,81 +42,44 @@ export default function DashboardNotificationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
setActionLoading(true)
|
||||
async function markAllRead(reload: () => Promise<void>) {
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
await refreshUnreadCount()
|
||||
await loadData()
|
||||
await reload()
|
||||
toast.success(t("notification.markAllReadSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("notification.markAllReadFailed"))
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("notification.markAllReadFailed")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(nextStatus: NotificationReadStatus) {
|
||||
setReadStatus(nextStatus)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
if (nextPage < 1 || nextPage === page) {
|
||||
return
|
||||
}
|
||||
setPage(nextPage)
|
||||
}
|
||||
|
||||
function handleLimitChange(nextLimit: number) {
|
||||
setLimit(nextLimit)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
|
||||
<RefreshCwIcon className={cn(loading && "animate-spin")} />
|
||||
{t("notification.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleMarkAllRead()}
|
||||
disabled={actionLoading || result.page.total === 0}
|
||||
>
|
||||
<CheckCheckIcon />
|
||||
{t("notification.markAllRead")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{readStatusOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={option.value === readStatus ? "default" : "outline"}
|
||||
onClick={() => handleStatusChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</DashboardToolbar>
|
||||
|
||||
<DashboardTableShell
|
||||
pagination={
|
||||
<ListPagination
|
||||
page={result.page.page}
|
||||
limit={result.page.limit}
|
||||
total={result.page.total}
|
||||
loading={loading}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{result.results.length > 0 ? (
|
||||
<DashboardListPage<NotificationItem>
|
||||
filters={[
|
||||
{
|
||||
name: "readStatus",
|
||||
label: t("notification.all"),
|
||||
type: "segment",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: readStatusOptions,
|
||||
},
|
||||
]}
|
||||
fetchList={fetchNotifications}
|
||||
renderToolbarActions={({ result, reload }) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void markAllRead(reload)}
|
||||
disabled={result.page.total === 0}
|
||||
>
|
||||
<CheckCheckIcon />
|
||||
{t("notification.markAllRead")}
|
||||
</Button>
|
||||
)}
|
||||
renderContent={({ result, loading }) =>
|
||||
result.results.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{result.results.map((item) => {
|
||||
const unread = !item.readAt
|
||||
@@ -168,8 +92,14 @@ export default function DashboardNotificationsPage() {
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BellIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{item.title || t("notification.fallbackTitle")}</span>
|
||||
{unread ? <Badge>{t("notification.unread")}</Badge> : <Badge variant="outline">{t("notification.read")}</Badge>}
|
||||
<span className="font-medium">
|
||||
{item.title || t("notification.fallbackTitle")}
|
||||
</span>
|
||||
{unread ? (
|
||||
<Badge>{t("notification.unread")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("notification.read")}</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</span>
|
||||
@@ -185,8 +115,14 @@ export default function DashboardNotificationsPage() {
|
||||
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
{loading ? t("notification.loading") : t("notification.empty")}
|
||||
</div>
|
||||
)}
|
||||
</DashboardTableShell>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
labels={{
|
||||
refresh: t("notification.refresh"),
|
||||
loading: t("notification.loading"),
|
||||
empty: t("notification.empty"),
|
||||
loadFailed: t("notification.loadFailed"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { KeyRoundIcon, RefreshCwIcon, RouteIcon, SearchIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { KeyRoundIcon, RouteIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardTableStateRow,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page"
|
||||
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 {
|
||||
fetchPermissions,
|
||||
type AdminPermission,
|
||||
type PageResult,
|
||||
} from "@/lib/api/admin"
|
||||
import { fetchPermissions, type AdminPermission } from "@/lib/api/admin"
|
||||
import { Status } from "@/lib/generated/enums"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n"
|
||||
@@ -35,191 +12,107 @@ import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permissi
|
||||
export default function DashboardPermissionsPage() {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const [keywordInput, setKeywordInput] = useState("")
|
||||
const [groupNameInput, setGroupNameInput] = useState("")
|
||||
const [statusFilterInput, setStatusFilterInput] = useState("all")
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [groupName, setGroupName] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [result, setResult] = useState<PageResult<AdminPermission>>({
|
||||
results: [],
|
||||
page: { page: 1, limit: 20, total: 0 },
|
||||
})
|
||||
const listStatusOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: t("status.all") },
|
||||
{ value: String(Status.Ok), label: t("status.ok") },
|
||||
{ value: String(Status.Disabled), label: t("status.disabled") },
|
||||
{ value: String(Status.Deleted), label: t("status.deleted") },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchPermissions({
|
||||
keyword: keyword.trim() || undefined,
|
||||
groupName: groupName.trim() || undefined,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
setResult(data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("permission.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [groupName, keyword, limit, page, statusFilter, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
function applyFilters() {
|
||||
setKeyword(keywordInput)
|
||||
setGroupName(groupNameInput)
|
||||
setStatusFilter(statusFilterInput)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
if (nextPage < 1 || nextPage === page) {
|
||||
return
|
||||
}
|
||||
setPage(nextPage)
|
||||
}
|
||||
const listStatusOptions = [
|
||||
{ value: "all", label: t("status.all") },
|
||||
{ value: String(Status.Ok), label: t("status.ok") },
|
||||
{ value: String(Status.Disabled), label: t("status.disabled") },
|
||||
{ value: String(Status.Deleted), label: t("status.deleted") },
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
|
||||
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
|
||||
{t("permission.refresh")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="relative w-full sm:w-72">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={t("permission.filterKeyword")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={groupNameInput}
|
||||
onChange={(event) => setGroupNameInput(event.target.value)}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={t("permission.filterGroup")}
|
||||
className="w-full sm:w-44"
|
||||
/>
|
||||
<div className="w-full sm:w-36">
|
||||
<OptionCombobox
|
||||
value={statusFilterInput}
|
||||
onChange={setStatusFilterInput}
|
||||
placeholder={t("status.all")}
|
||||
options={[...listStatusOptions]}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
||||
<SearchIcon />
|
||||
{t("permission.query")}
|
||||
</Button>
|
||||
</DashboardToolbar>
|
||||
<DashboardTableShell
|
||||
pagination={
|
||||
<ListPagination
|
||||
page={result.page.page}
|
||||
total={result.page.total}
|
||||
limit={limit}
|
||||
loading={loading}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={(nextLimit) => {
|
||||
setLimit(nextLimit)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead>{t("permission.columnPermission")}</TableHead>
|
||||
<TableHead>{t("permission.columnCode")}</TableHead>
|
||||
<TableHead>{t("permission.columnGroup")}</TableHead>
|
||||
<TableHead>{t("permission.columnApi")}</TableHead>
|
||||
<TableHead>{t("permission.columnStatus")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.results.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<KeyRoundIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{getPermissionDisplayName(item.code, item.name, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{item.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.code}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{getPermissionGroupName(item.groupName, locale)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge variant="secondary">{item.method || "ANY"}</Badge>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<RouteIcon className="size-3.5" />
|
||||
{item.apiPath || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={item.status === Status.Ok ? "secondary" : "outline"}
|
||||
>
|
||||
{getStatusLabel(item.status, t)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loading || result.results.length === 0 ? (
|
||||
<DashboardTableStateRow
|
||||
colSpan={5}
|
||||
loading={loading}
|
||||
loadingText={t("permission.loading")}
|
||||
emptyText={t("permission.empty")}
|
||||
/>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DashboardTableShell>
|
||||
</DashboardPage>
|
||||
<DashboardListPage<AdminPermission>
|
||||
filters={[
|
||||
{
|
||||
name: "keyword",
|
||||
label: t("permission.filterKeyword"),
|
||||
placeholder: t("permission.filterKeyword"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
inputClassName: "pl-9",
|
||||
icon: <SearchIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
name: "groupName",
|
||||
label: t("permission.filterGroup"),
|
||||
placeholder: t("permission.filterGroup"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-44",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("status.all"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: listStatusOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
]}
|
||||
fetchList={fetchPermissions}
|
||||
getItemId={(item) => item.id}
|
||||
columns={[
|
||||
{
|
||||
key: "permission",
|
||||
label: t("permission.columnPermission"),
|
||||
render: (item) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<KeyRoundIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{getPermissionDisplayName(item.code, item.name, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{item.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: t("permission.columnCode"),
|
||||
render: (item) => <Badge variant="outline">{item.code}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "group",
|
||||
label: t("permission.columnGroup"),
|
||||
render: (item) => getPermissionGroupName(item.groupName, locale),
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
label: t("permission.columnApi"),
|
||||
render: (item) => (
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge variant="secondary">{item.method || "ANY"}</Badge>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<RouteIcon className="size-3.5" />
|
||||
{item.apiPath || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: t("permission.columnStatus"),
|
||||
render: (item) => (
|
||||
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||
{getStatusLabel(item.status, t)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
labels={{
|
||||
refresh: t("permission.refresh"),
|
||||
query: t("permission.query"),
|
||||
loading: t("permission.loading"),
|
||||
empty: t("permission.empty"),
|
||||
loadFailed: t("permission.loadFailed"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,12 @@ export function DashboardCrudPage<TItem, TPayload>({
|
||||
)
|
||||
const { draftFilters, appliedFilters, setDraftFilter, applyFilters } =
|
||||
useDashboardCrudFilters(filters)
|
||||
const filtersKey = filters
|
||||
.map(
|
||||
(filter) =>
|
||||
`${filter.name}:${String(filter.defaultValue)}:${String(filter.allValue)}:${filter.trim ? "1" : "0"}:${filter.valueType ?? ""}`
|
||||
)
|
||||
.join("|")
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(pageSize)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -238,7 +244,8 @@ export function DashboardCrudPage<TItem, TPayload>({
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedFilters, fetchList, filters, labels.loadFailed, limit, page])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appliedFilters, fetchList, filtersKey, labels.loadFailed, limit, page])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export { DashboardCrudPage } from "./dashboard-crud-page"
|
||||
export { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog"
|
||||
export {
|
||||
buildDashboardCrudQuery,
|
||||
normalizeDashboardCrudPageResult,
|
||||
} from "./dashboard-crud-utils"
|
||||
export {
|
||||
createDashboardStatusColumn,
|
||||
createDashboardStatusToggleAction,
|
||||
@@ -15,6 +19,8 @@ export type {
|
||||
} from "./dashboard-crud-page"
|
||||
export type {
|
||||
DashboardCrudFormField,
|
||||
DashboardCrudFilterStateConfig,
|
||||
DashboardCrudPageResult,
|
||||
DashboardCrudQueryFilter,
|
||||
DashboardCrudQueryValue,
|
||||
} from "./dashboard-crud-utils"
|
||||
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
export function useDashboardCrudFilters(
|
||||
filters: ReadonlyArray<DashboardCrudFilterStateConfig>
|
||||
) {
|
||||
const defaultsKey = filters
|
||||
.map((filter) => `${filter.name}:${String(filter.defaultValue)}`)
|
||||
.join("|")
|
||||
const initialFilters = useMemo(
|
||||
() => buildDashboardCrudInitialFilters(filters),
|
||||
[filters]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[defaultsKey]
|
||||
)
|
||||
const [draftFilters, setDraftFilters] = useState(initialFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState(initialFilters)
|
||||
@@ -33,11 +37,23 @@ export function useDashboardCrudFilters(
|
||||
setAppliedFilters(draftFilters)
|
||||
}
|
||||
|
||||
function applyFilter(name: string, value: string | number | undefined) {
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
[name]: value,
|
||||
}))
|
||||
setAppliedFilters((current) => ({
|
||||
...current,
|
||||
[name]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
draftFilters,
|
||||
appliedFilters,
|
||||
setDraftFilter,
|
||||
setDraftFilters,
|
||||
applyFilter,
|
||||
applyFilters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import type { KeyboardEvent, ReactNode } from "react"
|
||||
import { RefreshCwIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardTableStateRow,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page"
|
||||
import { ListPagination } from "@/components/list-pagination"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import type {
|
||||
DashboardCrudPageResult,
|
||||
DashboardCrudQueryFilter,
|
||||
} from "@/components/dashboard/crud"
|
||||
import {
|
||||
useDashboardPagedList,
|
||||
type DashboardPagedListOptions,
|
||||
} from "./use-dashboard-paged-list"
|
||||
|
||||
export type DashboardListFilter = DashboardCrudQueryFilter & {
|
||||
label: string
|
||||
placeholder?: string
|
||||
defaultValue: string | number
|
||||
type?: "text" | "select" | "segment"
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
options?: ReadonlyArray<{ value: string; label: string }>
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export type DashboardListColumn<TItem> = {
|
||||
key: string
|
||||
label: ReactNode
|
||||
className?: string
|
||||
render: (item: TItem, context: DashboardListRenderContext<TItem>) => ReactNode
|
||||
}
|
||||
|
||||
export type DashboardListRenderContext<TItem> = {
|
||||
result: DashboardCrudPageResult<TItem>
|
||||
loading: boolean
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
export type DashboardListPageProps<TItem> = {
|
||||
filters?: DashboardListFilter[]
|
||||
fetchList: DashboardPagedListOptions<TItem>["fetchList"]
|
||||
columns?: DashboardListColumn<TItem>[]
|
||||
getItemId?: (item: TItem) => string | number
|
||||
renderContent?: (context: DashboardListRenderContext<TItem>) => ReactNode
|
||||
renderToolbarActions?: (context: DashboardListRenderContext<TItem>) => ReactNode
|
||||
getRowClassName?: (item: TItem) => string | undefined
|
||||
onRowClick?: (item: TItem) => void
|
||||
pageSize?: number
|
||||
enabled?: boolean
|
||||
layout?: "page" | "fragment"
|
||||
tableShellClassName?: string
|
||||
labels: {
|
||||
refresh?: string
|
||||
query?: string
|
||||
loading: string
|
||||
empty: string
|
||||
loadFailed: string
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardListPage<TItem>({
|
||||
filters = [],
|
||||
fetchList,
|
||||
columns,
|
||||
getItemId,
|
||||
renderContent,
|
||||
renderToolbarActions,
|
||||
getRowClassName,
|
||||
onRowClick,
|
||||
pageSize,
|
||||
enabled,
|
||||
layout = "page",
|
||||
tableShellClassName,
|
||||
labels,
|
||||
}: DashboardListPageProps<TItem>) {
|
||||
const list = useDashboardPagedList<TItem>({
|
||||
filters,
|
||||
fetchList,
|
||||
pageSize,
|
||||
enabled,
|
||||
loadFailed: labels.loadFailed,
|
||||
})
|
||||
const renderContext: DashboardListRenderContext<TItem> = {
|
||||
result: list.result,
|
||||
loading: list.loading,
|
||||
reload: list.loadData,
|
||||
}
|
||||
|
||||
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
list.applyFilters()
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<>
|
||||
{labels.refresh ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void list.loadData()}
|
||||
disabled={list.loading}
|
||||
>
|
||||
<RefreshCwIcon className={list.loading ? "animate-spin" : undefined} />
|
||||
{labels.refresh}
|
||||
</Button>
|
||||
) : null}
|
||||
{renderToolbarActions?.(renderContext)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{filters.map((filter) => {
|
||||
const value = list.draftFilters[filter.name]
|
||||
if (filter.type === "segment") {
|
||||
return (
|
||||
<div
|
||||
key={filter.name}
|
||||
className={filter.className ?? "flex flex-wrap gap-2"}
|
||||
>
|
||||
{(filter.options ?? []).map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={String(value) === option.value ? "default" : "outline"}
|
||||
onClick={() => list.applyFilter(filter.name, option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (filter.type === "select") {
|
||||
return (
|
||||
<div key={filter.name} className={filter.className ?? "w-full sm:w-40"}>
|
||||
<OptionCombobox
|
||||
value={String(value ?? "")}
|
||||
onChange={(nextValue) =>
|
||||
list.setDraftFilter(filter.name, nextValue || filter.defaultValue)
|
||||
}
|
||||
placeholder={filter.placeholder ?? filter.label}
|
||||
searchPlaceholder={filter.searchPlaceholder}
|
||||
emptyText={filter.emptyText}
|
||||
options={[...(filter.options ?? [])]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={filter.name} className={filter.className ?? "w-full sm:w-64"}>
|
||||
<div className={filter.icon ? "relative" : undefined}>
|
||||
{filter.icon ? (
|
||||
<div className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground">
|
||||
{filter.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(event) =>
|
||||
list.setDraftFilter(filter.name, event.target.value)
|
||||
}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={filter.placeholder ?? filter.label}
|
||||
className={filter.inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filters.some((filter) => filter.type !== "segment") ? (
|
||||
<Button variant="outline" onClick={list.applyFilters} disabled={list.loading}>
|
||||
<SearchIcon />
|
||||
{labels.query}
|
||||
</Button>
|
||||
) : null}
|
||||
</DashboardToolbar>
|
||||
|
||||
<DashboardTableShell
|
||||
className={tableShellClassName}
|
||||
pagination={
|
||||
<ListPagination
|
||||
page={list.result.page.page}
|
||||
total={list.result.page.total}
|
||||
limit={list.result.page.limit}
|
||||
loading={list.loading}
|
||||
onPageChange={list.handlePageChange}
|
||||
onLimitChange={list.handleLimitChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderContent ? (
|
||||
renderContent(renderContext)
|
||||
) : columns ? (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.key} className={column.className}>
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.result.results.map((item, index) => {
|
||||
const key = getItemId ? getItemId(item) : index
|
||||
return (
|
||||
<TableRow
|
||||
key={key}
|
||||
className={getRowClassName?.(item)}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key} className={column.className}>
|
||||
{column.render(item, renderContext)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{list.loading || list.result.results.length === 0 ? (
|
||||
<DashboardTableStateRow
|
||||
colSpan={columns.length}
|
||||
loading={list.loading}
|
||||
loadingText={labels.loading}
|
||||
emptyText={labels.empty}
|
||||
/>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</DashboardTableShell>
|
||||
</>
|
||||
)
|
||||
|
||||
if (layout === "fragment") {
|
||||
return content
|
||||
}
|
||||
|
||||
return <DashboardPage>{content}</DashboardPage>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { DashboardListPage } from "./dashboard-list-page"
|
||||
export { useDashboardPagedList } from "./use-dashboard-paged-list"
|
||||
export type {
|
||||
DashboardListColumn,
|
||||
DashboardListFilter,
|
||||
DashboardListPageProps,
|
||||
DashboardListRenderContext,
|
||||
} from "./dashboard-list-page"
|
||||
export type { DashboardPagedListOptions } from "./use-dashboard-paged-list"
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
buildDashboardCrudQuery,
|
||||
normalizeDashboardCrudPageResult,
|
||||
type DashboardCrudPageResult,
|
||||
type DashboardCrudFilterStateConfig,
|
||||
type DashboardCrudQueryFilter,
|
||||
type DashboardCrudQueryValue,
|
||||
} from "@/components/dashboard/crud"
|
||||
import { useDashboardCrudFilters } from "@/components/dashboard/crud"
|
||||
|
||||
export type DashboardPagedListFilter = DashboardCrudQueryFilter &
|
||||
DashboardCrudFilterStateConfig
|
||||
|
||||
export type DashboardPagedListOptions<TItem> = {
|
||||
filters: DashboardPagedListFilter[]
|
||||
fetchList: (
|
||||
query: Record<string, DashboardCrudQueryValue>
|
||||
) => Promise<DashboardCrudPageResult<TItem>>
|
||||
pageSize?: number
|
||||
loadFailed: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function useDashboardPagedList<TItem>({
|
||||
filters,
|
||||
fetchList,
|
||||
pageSize = 20,
|
||||
loadFailed,
|
||||
enabled = true,
|
||||
}: DashboardPagedListOptions<TItem>) {
|
||||
const filtersKey = filters
|
||||
.map(
|
||||
(filter) =>
|
||||
`${filter.name}:${String(filter.defaultValue)}:${String(filter.allValue)}:${filter.trim ? "1" : "0"}:${filter.valueType ?? ""}`
|
||||
)
|
||||
.join("|")
|
||||
const { draftFilters, appliedFilters, setDraftFilter, applyFilter, applyFilters } =
|
||||
useDashboardCrudFilters(filters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(pageSize)
|
||||
const [loading, setLoading] = useState(enabled)
|
||||
const [result, setResult] = useState<DashboardCrudPageResult<TItem>>({
|
||||
results: [],
|
||||
page: { page: 1, limit: pageSize, total: 0 },
|
||||
})
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setLoading(false)
|
||||
setResult({ results: [], page: { page: 1, limit, total: 0 } })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchList(
|
||||
buildDashboardCrudQuery({
|
||||
values: appliedFilters,
|
||||
filters,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
)
|
||||
setResult(normalizeDashboardCrudPageResult(data, page, limit))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : loadFailed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appliedFilters, enabled, fetchList, filtersKey, limit, loadFailed, page])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
function applyDraftFilters() {
|
||||
applyFilters()
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function applyDraftFilter(name: string, value: string | number | undefined) {
|
||||
applyFilter(name, value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
if (nextPage < 1 || nextPage === page) return
|
||||
setPage(nextPage)
|
||||
}
|
||||
|
||||
function handleLimitChange(nextLimit: number) {
|
||||
if (nextLimit <= 0 || nextLimit === limit) return
|
||||
setLimit(nextLimit)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return {
|
||||
draftFilters,
|
||||
setDraftFilter,
|
||||
applyFilter: applyDraftFilter,
|
||||
applyFilters: applyDraftFilters,
|
||||
page,
|
||||
setPage,
|
||||
limit,
|
||||
setLimit,
|
||||
loading,
|
||||
result,
|
||||
setResult,
|
||||
loadData,
|
||||
handlePageChange,
|
||||
handleLimitChange,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user