update web to dashboard

This commit is contained in:
mlogclub
2026-04-15 17:20:57 +08:00
parent 3d98cf1e27
commit c07e588030
206 changed files with 0 additions and 0 deletions
@@ -0,0 +1,71 @@
"use client"
import Link from "next/link"
import { ArrowRightIcon, ShieldAlertIcon } from "lucide-react"
import type { DashboardAlert } from "@/lib/api/dashboard"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type AlertListProps = {
alerts: DashboardAlert[]
}
function getAlertBadgeVariant(level: DashboardAlert["level"]) {
if (level === "error") {
return "destructive" as const
}
if (level === "warning") {
return "secondary" as const
}
return "outline" as const
}
export function AlertList({ alerts }: AlertListProps) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{alerts.length === 0 ? (
<div className="rounded-2xl border border-dashed px-4 py-10 text-center">
<ShieldAlertIcon className="mx-auto mb-3 size-8 text-muted-foreground" />
<div className="text-sm font-medium"></div>
<div className="mt-1 text-sm text-muted-foreground">
AI
</div>
</div>
) : (
alerts.map((item) => (
<Link key={item.id} href={item.link} className="block">
<div className="rounded-2xl border p-4 transition-colors hover:border-primary/40">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<div className="font-medium">{item.title}</div>
<Badge variant={getAlertBadgeVariant(item.level)}>
{item.count}
</Badge>
</div>
<div className="mt-1 text-sm text-muted-foreground">
{item.description}
</div>
</div>
<ArrowRightIcon className="mt-0.5 size-4 text-muted-foreground" />
</div>
</div>
</Link>
))
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,105 @@
"use client"
import Link from "next/link"
import {
BotMessageSquareIcon,
CircleDashedIcon,
HeadsetIcon,
SparklesIcon,
WavesIcon,
} from "lucide-react"
import type { DashboardOverview } from "@/lib/api/dashboard"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type SummaryCardsProps = {
summary: DashboardOverview["summary"]
}
type SummaryCardItem = {
key: keyof DashboardOverview["summary"]
title: string
description: string
link: string
icon: typeof BotMessageSquareIcon
format?: (value: number) => string
}
const cards: SummaryCardItem[] = [
{
key: "todayNewConversations",
title: "今日新增会话",
description: "今日进入系统的新增咨询量",
link: "/conversations",
icon: BotMessageSquareIcon,
},
{
key: "processingConversations",
title: "当前处理中",
description: "正在由 AI 或人工接待的会话",
link: "/conversations",
icon: WavesIcon,
},
{
key: "pendingDispatchConversations",
title: "待分配会话",
description: "仍在待接入池中等待分配",
link: "/conversations",
icon: CircleDashedIcon,
},
{
key: "onlineAgents",
title: "在线客服",
description: "近 15 分钟内仍有活跃心跳的客服",
link: "/agents",
icon: HeadsetIcon,
},
{
key: "aiServiceRate",
title: "AI 接待占比",
description: "当前活跃会话中 AI 参与服务比例",
link: "/ai-agents",
icon: SparklesIcon,
format: (value: number) => `${value.toFixed(1)}%`,
},
]
export function SummaryCards({ summary }: SummaryCardsProps) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{cards.map((item) => {
const Icon = item.icon
const rawValue = summary[item.key]
const value =
typeof item.format === "function"
? item.format(Number(rawValue))
: Number(rawValue).toLocaleString()
return (
<Link key={item.key} href={item.link}>
<Card className="h-full transition-colors hover:border-primary/40">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-sm font-medium">{item.title}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</div>
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Icon className="size-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold tracking-tight">{value}</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)
}
@@ -0,0 +1,98 @@
"use client"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import type { DashboardOverview } from "@/lib/api/dashboard"
type TeamLoadPanelProps = {
agentStats: DashboardOverview["agentStats"]
}
function getLoadTone(loadRate: number) {
if (loadRate >= 85) {
return "bg-red-500"
}
if (loadRate >= 60) {
return "bg-amber-500"
}
return "bg-emerald-500"
}
export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
线 {agentStats.onlineAgents} {agentStats.busyAgents}线 {agentStats.offlineAgents}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{agentStats.teamLoads.length === 0 ? (
<div className="rounded-xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
</div>
) : (
agentStats.teamLoads.map((item) => (
<div key={item.teamId} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<div className="font-medium">{item.teamName}</div>
{item.hasScheduleNow ? (
<Badge variant="secondary"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</div>
<div className="mt-1 text-sm text-muted-foreground">
{item.totalAgents}线 {item.onlineAgents} {item.busyAgents}线{" "}
{item.offlineAgents}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-semibold">{item.loadRate.toFixed(1)}%</div>
<div className="text-xs text-muted-foreground">
{item.processingConversations}/{item.maxConcurrentCapacity || 0}
</div>
</div>
</div>
<div className="mt-4 h-2 rounded-full bg-muted">
<div
className={`h-2 rounded-full ${getLoadTone(item.loadRate)}`}
style={{ width: `${Math.min(item.loadRate, 100)}%` }}
/>
</div>
<div className="mt-4 grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl bg-muted/40 px-3 py-2">
<div className="text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold">{item.waitingConversations}</div>
</div>
<div className="rounded-xl bg-muted/40 px-3 py-2">
<div className="text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold">{item.processingConversations}</div>
</div>
<div className="rounded-xl bg-muted/40 px-3 py-2">
<div className="text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold">{item.maxConcurrentCapacity}</div>
</div>
<div className="rounded-xl bg-muted/40 px-3 py-2">
<div className="text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold">{item.busyAgents}</div>
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,117 @@
"use client"
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import type { DashboardStatusDistributionItem, DashboardTrendItem } from "@/lib/api/dashboard"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
type TrendPanelProps = {
title: string
description: string
trend: DashboardTrendItem[]
distribution: DashboardStatusDistributionItem[]
}
const trendConfig = {
newCount: {
label: "新增",
color: "hsl(24 95% 53%)",
},
closedCount: {
label: "关闭",
color: "hsl(190 95% 39%)",
},
} satisfies ChartConfig
const distributionConfig = {
count: {
label: "数量",
theme: {
light: "hsl(222 47% 11%)",
dark: "hsl(210 40% 98%)",
},
},
} satisfies ChartConfig
export function TrendPanel({
title,
description,
trend,
distribution,
}: TrendPanelProps) {
return (
<div className="grid gap-4 xl:grid-cols-[1.5fr_0.9fr]">
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={trendConfig} className="h-72 w-full">
<AreaChart data={trend}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="newCount"
stroke="var(--color-newCount)"
fill="var(--color-newCount)"
fillOpacity={0.18}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="closedCount"
stroke="var(--color-closedCount)"
fill="var(--color-closedCount)"
fillOpacity={0.08}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ChartContainer config={distributionConfig} className="h-72 w-full">
<BarChart data={distribution} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid horizontal={false} />
<YAxis
type="category"
dataKey="label"
tickLine={false}
axisLine={false}
width={64}
/>
<XAxis type="number" hide />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar
dataKey="count"
fill="var(--color-count)"
radius={[0, 8, 8, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,402 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { BotMessageSquareIcon, WorkflowIcon } from "lucide-react"
import { toast } from "sonner"
import { ImMessageHTML } from "@/components/im-message-html"
import { JsonTreeViewer } from "@/components/json-tree-viewer"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import { fetchAgentRunLog, type AgentRunLog } from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
type AgentRunLogDetailDialogProps = {
open: boolean
logId: number | null
onOpenChange: (open: boolean) => void
}
export function AgentRunLogDetailDialog({
open,
logId,
onOpenChange,
}: AgentRunLogDetailDialogProps) {
const [loading, setLoading] = useState(false)
const [activeLog, setActiveLog] = useState<AgentRunLog | null>(null)
useEffect(() => {
if (!open || !logId) {
return
}
let cancelled = false
const currentLogId = logId
async function loadDetail() {
setLoading(true)
try {
const data = await fetchAgentRunLog(currentLogId)
if (!cancelled) {
setActiveLog(data)
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载日志详情失败")
onOpenChange(false)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void loadDetail()
return () => {
cancelled = true
}
}, [logId, onOpenChange, open])
useEffect(() => {
if (open) {
return
}
setLoading(false)
setActiveLog(null)
}, [open])
const activeTraceData = useMemo(
() => safeParseJSON(activeLog?.traceData ?? ""),
[activeLog?.traceData]
)
const activeToolSearchTrace = useMemo(
() => safeParseJSON(activeLog?.toolSearchTrace ?? ""),
[activeLog?.toolSearchTrace]
)
const activeGraphToolTrace = useMemo(
() => safeParseJSON(activeLog?.graphToolTrace ?? ""),
[activeLog?.graphToolTrace]
)
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<WorkflowIcon className="size-4" />
Agent
</span>
}
description="查看 planner 选择、最终动作、回复内容与错误信息。"
size="xl"
allowFullscreen
defaultFullscreen
bodyClassName="min-h-0"
footer={
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
}
>
{loading ? (
<div className="py-10 text-sm text-muted-foreground">...</div>
) : activeLog ? (
<>
<MetaStrip
items={[
{ label: "日志ID", value: String(activeLog.id) },
{ label: "会话ID", value: String(activeLog.conversationId || "-") },
{ label: "消息ID", value: String(activeLog.messageId || "-") },
{ label: "AI Agent", value: String(activeLog.aiAgentId || "-") },
]}
/>
<InfoBlock
title="规划阶段"
lines={[
`plannedAction: ${activeLog.plannedAction || "-"}`,
`plannedSkillCode: ${activeLog.plannedSkillCode || "-"}`,
`plannedSkillName: ${activeLog.plannedSkillName || "-"}`,
`graphToolCode: ${activeLog.graphToolCode || "-"}`,
`recommendedAction: ${activeLog.recommendedAction || "-"}`,
`riskLevel: ${activeLog.riskLevel || "-"}`,
`ticketDraftReady: ${activeLog.ticketDraftReady ? "true" : "false"}`,
`plannedToolCode: ${activeLog.plannedToolCode || "-"}`,
`planReason: ${activeLog.planReason || "-"}`,
`handoffReason: ${activeLog.handoffReason || "-"}`,
`skillRouteTrace: ${activeLog.skillRouteTrace || "-"}`,
]}
/>
<InfoBlock
title="HITL 状态"
lines={[
`hitlStatus: ${activeLog.hitlStatus || "-"}`,
`hitlStatusName: ${activeLog.hitlStatusName || "-"}`,
`hitlSummary: ${activeLog.hitlSummary || "-"}`,
]}
/>
<InfoBlock
title="执行结果"
lines={[
`finalAction: ${activeLog.finalAction || "-"}`,
`finalStatus: ${activeLog.finalStatus || "-"}`,
`interruptType: ${activeLog.interruptType || "-"}`,
`resumeSource: ${activeLog.resumeSource || "-"}`,
`latencyMs: ${activeLog.latencyMs} ms`,
`createdAt: ${formatDateTime(activeLog.createdAt)}`,
]}
/>
<JsonBlock
title="动态工具选择"
jsonValue={activeToolSearchTrace}
fallbackValue={activeLog.toolSearchTrace}
/>
<JsonBlock
title="Graph Tool 调用"
jsonValue={activeGraphToolTrace}
fallbackValue={activeLog.graphToolTrace}
/>
<TextBlock
icon={<BotMessageSquareIcon className="size-4" />}
title="用户问题"
value={activeLog.userMessage}
renderAsHtml
/>
<TextBlock
icon={<WorkflowIcon className="size-4" />}
title="机器人回复"
value={activeLog.replyText}
/>
<TextBlock title="错误信息" value={activeLog.errorMessage} tone="danger" />
<JsonBlock
title="链路 Trace"
jsonValue={activeTraceData}
fallbackValue={activeLog.traceData}
/>
</>
) : (
<div className="py-10 text-sm text-muted-foreground"></div>
)}
</ProjectDialog>
)
}
function safeParseJSON(value: string) {
if (!value.trim()) {
return null
}
try {
return JSON.parse(value)
} catch {
return null
}
}
function MetaStrip({
items,
}: {
items: Array<{ label: string; value: string }>
}) {
return (
<div className="rounded-lg border bg-muted/20 px-4 py-3">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
{items.map((item) => (
<div key={item.label} className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">{item.label}</span>
<span className="min-w-0 truncate font-medium">{item.value}</span>
</div>
))}
</div>
</div>
)
}
function InfoBlock({ title, lines }: { title: string; lines: string[] }) {
return (
<div className="rounded-lg border p-4">
<div className="text-sm font-medium">{title}</div>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
{lines.map((line) => (
<div key={line}>{line}</div>
))}
</div>
</div>
)
}
function TextBlock({
title,
value,
icon,
tone = "default",
renderAsHtml = false,
}: {
title: string
value?: string
icon?: ReactNode
tone?: "default" | "danger"
renderAsHtml?: boolean
}) {
const normalizedValue = value?.trim() || ""
const html = useMemo(() => {
if (!renderAsHtml || !normalizedValue) {
return ""
}
return sanitizeRichHTML(normalizedValue)
}, [normalizedValue, renderAsHtml])
return (
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm font-medium">
{icon}
{title}
</div>
{renderAsHtml && normalizedValue ? (
<ImMessageHTML
html={html}
className="mt-3 select-text text-muted-foreground"
/>
) : (
<div
className={
tone === "danger"
? "mt-3 select-text whitespace-pre-wrap wrap-break-word text-sm text-destructive"
: "mt-3 select-text whitespace-pre-wrap wrap-break-word text-sm text-muted-foreground"
}
>
{normalizedValue || "-"}
</div>
)}
</div>
)
}
function JsonBlock({
title,
jsonValue,
fallbackValue,
}: {
title: string
jsonValue: unknown
fallbackValue?: string
}) {
const normalizedFallback = fallbackValue?.trim() || ""
return (
<div className="rounded-lg border p-4">
<div className="text-sm font-medium">{title}</div>
{jsonValue ? (
<JsonTreeViewer value={jsonValue} className="mt-3" />
) : (
<div className="mt-3 select-text whitespace-pre-wrap wrap-break-word text-sm text-muted-foreground">
{normalizedFallback || "-"}
</div>
)}
</div>
)
}
function sanitizeRichHTML(value: string) {
if (typeof window === "undefined") {
return value
}
const doc = new DOMParser().parseFromString(value, "text/html")
const allowedTags = new Set([
"a",
"b",
"blockquote",
"br",
"code",
"div",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"u",
"ul",
])
const allowedAttrs = new Set([
"alt",
"class",
"colspan",
"href",
"rel",
"rowspan",
"src",
"target",
"title",
])
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT)
const elements: Element[] = []
while (walker.nextNode()) {
elements.push(walker.currentNode as Element)
}
for (const element of elements) {
const tag = element.tagName.toLowerCase()
if (!allowedTags.has(tag)) {
element.replaceWith(...Array.from(element.childNodes))
continue
}
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase()
const attrValue = attr.value.trim()
if (name.startsWith("on") || !allowedAttrs.has(name)) {
element.removeAttribute(attr.name)
continue
}
if ((name === "href" || name === "src") && !isSafeURL(attrValue)) {
element.removeAttribute(attr.name)
}
}
if (tag === "a") {
element.setAttribute("target", "_blank")
element.setAttribute("rel", "noreferrer noopener")
}
}
return doc.body.innerHTML
}
function isSafeURL(value: string) {
if (!value) {
return false
}
if (value.startsWith("/")) {
return true
}
if (value.startsWith("data:image/")) {
return true
}
try {
const url = new URL(value, window.location.origin)
return ["http:", "https:"].includes(url.protocol)
} catch {
return false
}
}
@@ -0,0 +1,427 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
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 { AgentRunLogDetailDialog } from "./_components/detail"
import {
fetchAgentRunLogs,
fetchAIAgentsAll,
type AIAgent,
type AgentRunLog,
type PageResult,
} from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
const actionOptions = [
{ value: "all", label: "全部动作" },
{ value: "rag", label: "RAG" },
{ value: "skill", label: "Skill" },
{ value: "tool", label: "Tool" },
{ value: "graph", label: "Graph" },
{ value: "handoff", label: "转人工" },
{ value: "reply", label: "回复" },
{ value: "fallback", label: "兜底" },
]
const finalStatusOptions = [
{ value: "all", label: "全部状态" },
{ value: "completed", label: "completed" },
{ value: "interrupted", label: "interrupted" },
{ value: "expired", label: "expired" },
{ value: "error", label: "error" },
{ value: "fallback", label: "fallback" },
]
const hitlStatusOptions = [
{ value: "all", label: "全部 HITL" },
{ value: "pending", label: "等待确认" },
{ value: "confirmed", label: "已确认" },
{ value: "cancelled", label: "已取消" },
{ value: "expired", label: "已过期" },
{ value: "triggered", label: "已触发" },
]
function actionBadgeVariant(action: string) {
switch (action) {
case "handoff":
return "destructive" as const
case "skill":
return "default" as const
case "tool":
return "default" as const
case "graph":
return "default" as const
case "rag":
return "secondary" as const
case "fallback":
return "outline" as const
default:
return "secondary" as const
}
}
function hitlBadgeVariant(status: string) {
switch (status) {
case "pending":
return "secondary" as const
case "confirmed":
return "default" as const
case "cancelled":
return "outline" as const
case "expired":
return "destructive" as const
default:
return "secondary" as const
}
}
export default function DashboardAgentRunLogsPage() {
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 aiAgentOptions = useMemo(
() => [
{ value: "all", label: "全部 Agent" },
...aiAgents.map((item) => ({
value: String(item.id),
label: item.name,
})),
],
[aiAgents]
)
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 : "加载 Agent 运行日志失败")
} finally {
setLoading(false)
}
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
async function loadAIAgents() {
try {
const data = await fetchAIAgentsAll()
setAiAgents(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI Agent 列表失败")
}
}
void loadAIAgents()
}, [])
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 (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-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="按用户问题筛选"
className="pl-9"
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={plannedActionInput}
options={actionOptions}
placeholder="规划动作"
searchPlaceholder="搜索动作"
emptyText="未找到动作"
onChange={(value) => setPlannedActionInput(value || "all")}
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={finalActionInput}
options={actionOptions}
placeholder="最终动作"
searchPlaceholder="搜索动作"
emptyText="未找到动作"
onChange={(value) => setFinalActionInput(value || "all")}
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={finalStatusInput}
options={finalStatusOptions}
placeholder="最终状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
onChange={(value) => setFinalStatusInput(value || "all")}
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={hitlStatusInput}
options={hitlStatusOptions}
placeholder="HITL 状态"
searchPlaceholder="搜索 HITL 状态"
emptyText="未找到状态"
onChange={(value) => setHitlStatusInput(value || "all")}
/>
</div>
<div className="w-full xl:w-52">
<OptionCombobox
value={aiAgentIdInput}
options={aiAgentOptions}
placeholder="选择 Agent"
searchPlaceholder="搜索 Agent"
emptyText="未找到 Agent"
onChange={(value) => setAiAgentIdInput(value || "all")}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-[180px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[220px]">Skill / Tool</TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[110px] text-right"></TableHead>
<TableHead className="w-[96px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-14 text-center text-muted-foreground">
Agent
</TableCell>
</TableRow>
) : null}
{result.results.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-sm text-muted-foreground">
{formatDateTime(item.createdAt)}
</TableCell>
<TableCell>
<UserMessagePreview value={item.userMessage} />
{item.errorMessage ? (
<div className="mt-1 line-clamp-1 text-xs text-destructive">
{item.errorMessage}
</div>
) : null}
</TableCell>
<TableCell>
<Badge variant={actionBadgeVariant(item.plannedAction)}>
{item.plannedAction || "-"}
</Badge>
</TableCell>
<TableCell className="text-sm">
{item.plannedSkillCode || item.plannedToolCode ? (
<div className="space-y-1">
<Badge variant="outline">
{item.plannedSkillCode || item.graphToolCode || item.plannedToolCode}
</Badge>
{item.plannedSkillName ? (
<div className="line-clamp-1 text-xs text-muted-foreground">
{item.plannedSkillName}
</div>
) : null}
{item.handoffReason ? (
<div className="line-clamp-1 text-xs text-muted-foreground">
{item.handoffReason}
</div>
) : null}
{item.recommendedAction ? (
<div className="line-clamp-1 text-xs text-muted-foreground">
{item.recommendedAction}
{item.riskLevel ? ` / ${item.riskLevel} risk` : ""}
{item.ticketDraftReady ? " / 草稿已就绪" : ""}
</div>
) : null}
</div>
) : (
"-"
)}
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={actionBadgeVariant(item.finalAction)}>
{item.finalAction || "-"}
</Badge>
{item.hitlStatusName ? (
<div>
<Badge variant={hitlBadgeVariant(item.hitlStatus)}>
{item.hitlStatusName}
</Badge>
</div>
) : null}
<div className="text-xs text-muted-foreground">
{item.finalStatus || "-"}
</div>
</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{item.latencyMs} ms
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => {
setActiveLogId(item.id)
setDetailOpen(true)
}}
>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="border-t px-4 py-3">
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={setPage}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
</div>
</div>
<AgentRunLogDetailDialog
open={detailOpen}
logId={activeLogId}
onOpenChange={(open) => {
setDetailOpen(open)
if (!open) {
setActiveLogId(null)
}
}}
/>
</>
)
}
function UserMessagePreview({ value }: { value?: string }) {
const preview = useMemo(() => summarizeUserMessage(value), [value])
return (
<div className="line-clamp-2 max-w-[620px] text-sm text-muted-foreground">
{preview}
</div>
)
}
function summarizeUserMessage(value?: string) {
const normalized = value?.trim()
if (!normalized) {
return "-"
}
const text = extractTextFromHTML(normalized).replace(/\s+/g, " ").trim()
if (text) {
return text
}
if (containsHTML(normalized)) {
if (/<img[\s>]/i.test(normalized)) {
return "[图片]"
}
return "[富文本消息]"
}
return normalized
}
function containsHTML(value: string) {
return /<[^>]+>/.test(value)
}
function extractTextFromHTML(value: string) {
if (typeof window === "undefined") {
return value
}
const doc = new DOMParser().parseFromString(value, "text/html")
return doc.body.textContent || ""
}
@@ -0,0 +1,283 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { OptionCombobox } from "@/components/option-combobox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import {
type AdminAgentTeam,
type AdminAgentTeamSchedule,
type CreateAdminAgentTeamSchedulePayload,
fetchAgentTeamSchedule,
fetchAgentTeamsAll
} from "@/lib/api/admin"
type ScheduleEditDialogProps = {
open: boolean
saving: boolean
itemId: number | null
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateAdminAgentTeamSchedulePayload) => Promise<void>
}
const sourceTypeOptions = [
{ value: "manual", label: "手工录入" },
{ value: "batch_import", label: "批量导入" },
{ value: "template_generate", label: "模板生成" },
] as const
const emptyForm: EditForm = {
teamId: "",
startAt: "",
endAt: "",
sourceType: "manual",
remark: "",
}
const editFormSchema = z.object({
teamId: z.string().trim().regex(/^\d+$/, "请选择客服组"),
startAt: z.string().trim().min(1, "开始时间不能为空"),
endAt: z.string().trim().min(1, "结束时间不能为空"),
sourceType: z.enum(["manual", "batch_import", "template_generate"], { message: "请选择排班来源" }),
remark: z.string().trim(),
})
type EditForm = z.infer<typeof editFormSchema>
const editFormResolver = zodResolver(editFormSchema as never) as Resolver<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>
function toDateTimeLocal(value?: string) {
if (!value) {
return ""
}
return value.replace(" ", "T").slice(0, 16)
}
function buildForm(item: AdminAgentTeamSchedule | null): EditForm {
if (!item) {
return emptyForm
}
return {
teamId: String(item.teamId),
startAt: toDateTimeLocal(item.startAt),
endAt: toDateTimeLocal(item.endAt),
sourceType: item.sourceType as EditForm["sourceType"],
remark: item.remark || "",
}
}
function buildPayload(form: EditForm): CreateAdminAgentTeamSchedulePayload {
return {
teamId: Number(form.teamId),
startAt: form.startAt.trim(),
endAt: form.endAt.trim(),
sourceType: form.sourceType,
remark: form.remark.trim(),
}
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: ScheduleEditDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<ScheduleEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
itemId={itemId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
) : null}
</Dialog>
)
}
type ScheduleEditDialogBodyProps = Omit<ScheduleEditDialogProps, "open">
function ScheduleEditDialogBody({
saving,
itemId,
onOpenChange,
onSubmit,
}: ScheduleEditDialogBodyProps) {
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [loading, setLoading] = useState(false)
const loadOptions = useCallback(async () => {
try {
const teamsData = await fetchAgentTeamsAll()
setTeams(teamsData)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败")
}
}, [])
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
reset,
register,
formState: { errors },
} = form
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm)
return
}
setLoading(true)
try {
const data = await fetchAgentTeamSchedule(itemId)
reset(buildForm(data))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班详情失败")
} finally {
setLoading(false)
}
}
void loadDetail()
}, [itemId, reset])
useEffect(() => {
void loadOptions()
}, [loadOptions])
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values))
}
return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑客服组排班" : "新建客服组排班"}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<div className="grid grid-cols-1 gap-4">
<Field data-invalid={!!errors.teamId}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="teamId"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={teams.map((team) => ({
value: String(team.id),
label: team.name,
}))}
placeholder="请选择客服组"
searchPlaceholder="搜索客服组"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.teamId]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.startAt}>
<FieldLabel htmlFor="agent-team-schedule-start-at"></FieldLabel>
<FieldContent>
<Input id="agent-team-schedule-start-at" type="datetime-local" {...register("startAt")} />
<FieldError errors={[errors.startAt]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.endAt}>
<FieldLabel htmlFor="agent-team-schedule-end-at"></FieldLabel>
<FieldContent>
<Input id="agent-team-schedule-end-at" type="datetime-local" {...register("endAt")} />
<FieldError errors={[errors.endAt]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.sourceType}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="sourceType"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} modal={false}>
<SelectTrigger className="w-full">
<SelectValue>
{sourceTypeOptions.find((item) => item.value === field.value)?.label ?? "请选择来源"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{sourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.sourceType]} />
</FieldContent>
</Field>
</div>
<Field>
<FieldLabel htmlFor="agent-team-schedule-remark"></FieldLabel>
<FieldContent>
<Textarea id="agent-team-schedule-remark" rows={4} placeholder="请输入备注" {...register("remark")} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
)
}
@@ -0,0 +1,284 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import {
CalendarClockIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "sonner"
import {
createAgentTeamSchedule,
deleteAgentTeamSchedule,
fetchAgentTeamSchedules,
fetchAgentTeams,
updateAgentTeamSchedule,
type AdminAgentTeam,
type AdminAgentTeamSchedule,
type CreateAdminAgentTeamSchedulePayload,
type PageResult,
} from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
import { EditDialog } from "./_components/edit"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { ListPagination } from "@/components/list-pagination"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export default function DashboardAgentTeamSchedulesPage() {
const [teamFilterInput, setTeamFilterInput] = useState("all")
const [teamFilter, setTeamFilter] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<AdminAgentTeamSchedule | null>(null)
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [result, setResult] = useState<PageResult<AdminAgentTeamSchedule>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchAgentTeamSchedules({
teamId: teamFilter === "all" ? undefined : teamFilter,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班失败")
} finally {
setLoading(false)
}
}, [limit, page, teamFilter])
const loadTeams = useCallback(async () => {
try {
const data = await fetchAgentTeams()
setTeams(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败")
}
}, [])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
void loadTeams()
}, [loadTeams])
function applyFilters() {
setTeamFilter(teamFilterInput)
setPage(1)
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return
}
setPage(nextPage)
}
function openCreateDialog() {
setEditingItem(null)
setDialogOpen(true)
}
function openEditDialog(item: AdminAgentTeamSchedule) {
setEditingItem(item)
setDialogOpen(true)
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return
}
if (!open) {
setEditingItem(null)
}
setDialogOpen(open)
}
async function handleSubmit(payload: CreateAdminAgentTeamSchedulePayload) {
if (saving) {
return
}
setSaving(true)
try {
if (editingItem) {
await updateAgentTeamSchedule({ id: editingItem.id, ...payload })
toast.success("已更新客服组排班")
} else {
await createAgentTeamSchedule(payload)
toast.success("已创建客服组排班")
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组排班失败")
} finally {
setSaving(false)
}
}
async function handleDelete(item: AdminAgentTeamSchedule) {
setActionLoadingId(item.id)
try {
await deleteAgentTeamSchedule(item.id)
toast.success("已删除客服组排班")
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组排班失败")
} finally {
setActionLoadingId(null)
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<Select value={teamFilterInput} onValueChange={(value) => setTeamFilterInput(value ?? "all")}>
<SelectTrigger className="w-full xl:w-48">
<SelectValue placeholder="筛选客服组" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={String(team.id)}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<CalendarClockIcon className="size-4" />
</div>
<div className="min-w-0">
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div>
<div className="text-xs text-muted-foreground">ID{item.teamId}</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm">{formatDateTime(item.startAt)}</div>
<div className="text-sm text-muted-foreground">{formatDateTime(item.endAt)}</div>
</TableCell>
<TableCell>
<div className="text-sm">{item.sourceType}</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.startAt}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
)
}
@@ -0,0 +1,527 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
import { ImageInput } from "@/components/image-input";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
fetchAgentProfile,
fetchUsersAll,
type AdminAgentProfile,
type AdminUser,
type CreateAdminAgentProfilePayload
} from "@/lib/api/admin";
import {
ServiceStatus,
ServiceStatusLabels,
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
type AgentEditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
defaultTeamId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
};
const serviceStatusOptions = getEnumOptions(ServiceStatusLabels);
const emptyForm: EditForm = {
userId: "",
teamId: "",
agentCode: "",
displayName: "",
avatar: "",
serviceStatus: String(ServiceStatus.Idle) as "0" | "1",
maxConcurrentCount: "0",
priorityLevel: "0",
autoAssignEnabled: true,
receiveOfflineMessage: false,
remark: "",
};
const editFormSchema = z.object({
userId: z.string().trim().min(1, "请选择关联用户"),
teamId: z.string().trim().min(1, "请选择所属客服组"),
agentCode: z.string().trim().min(1, "客服工号不能为空"),
displayName: z.string().trim().min(1, "展示名不能为空"),
avatar: z.string().trim(),
serviceStatus: z.enum(["0", "1"], {
message: "请选择客服状态",
}),
maxConcurrentCount: z
.string()
.trim()
.regex(/^\d+$/, "最大并发必须是大于等于 0 的整数"),
priorityLevel: z
.string()
.trim()
.regex(/^-?\d+$/, "优先级必须是整数"),
autoAssignEnabled: z.boolean(),
receiveOfflineMessage: z.boolean(),
remark: z.string().trim(),
});
type EditForm = z.infer<typeof editFormSchema>;
const editFormResolver = zodResolver(editFormSchema as never) as Resolver<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>;
function getStatusLabel(value: string) {
return getEnumLabel(
ServiceStatusLabels,
Number(value) as ServiceStatus,
);
}
function buildForm(item: AdminAgentProfile | null): EditForm {
if (!item) {
return emptyForm;
}
return {
userId: String(item.userId),
teamId: String(item.teamId),
agentCode: item.agentCode,
displayName: item.displayName,
avatar: item.avatar || "",
serviceStatus: String(item.serviceStatus) as EditForm["serviceStatus"],
maxConcurrentCount: String(item.maxConcurrentCount),
priorityLevel: String(item.priorityLevel),
autoAssignEnabled: item.autoAssignEnabled,
receiveOfflineMessage: item.receiveOfflineMessage,
remark: item.remark || "",
};
}
function buildFormWithDefaultTeam(
item: AdminAgentProfile | null,
defaultTeamId: number | null,
): EditForm {
const form = buildForm(item);
if (!item && defaultTeamId) {
return {
...form,
teamId: String(defaultTeamId),
};
}
return form;
}
function buildPayload(form: EditForm): CreateAdminAgentProfilePayload {
return {
userId: Number(form.userId),
teamId: Number(form.teamId),
agentCode: form.agentCode.trim(),
displayName: form.displayName.trim(),
avatar: form.avatar.trim(),
serviceStatus: Number(form.serviceStatus),
maxConcurrentCount: Number(form.maxConcurrentCount),
priorityLevel: Number(form.priorityLevel),
autoAssignEnabled: form.autoAssignEnabled,
receiveOfflineMessage: form.receiveOfflineMessage,
remark: form.remark.trim(),
};
}
export function EditDialog({
open,
saving,
itemId,
defaultTeamId,
onOpenChange,
onSubmit,
}: AgentEditDialogProps) {
if (!open) {
return null;
}
return (
<AgentEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
itemId={itemId}
defaultTeamId={defaultTeamId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type AgentEditDialogBodyProps = {
open: boolean;
saving: boolean;
itemId: number | null;
defaultTeamId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
};
function AgentEditDialogBody({
open,
saving,
itemId,
defaultTeamId,
onOpenChange,
onSubmit,
}: AgentEditDialogBodyProps) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSelectOpen, setUserSelectOpen] = useState(false);
const [loading, setLoading] = useState(false);
const userOptions = users.map((user) => ({
value: String(user.id),
label: `${user.nickname || user.username} (${user.username})`,
}));
const loadOptions = useCallback(async () => {
try {
const [usersData] = await Promise.all([
fetchUsersAll(),
]);
setUsers(usersData);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败");
}
}, []);
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
resolver: editFormResolver,
defaultValues: buildFormWithDefaultTeam(null, defaultTeamId),
});
const {
control,
handleSubmit,
reset,
register,
formState: { errors },
} = form;
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(buildFormWithDefaultTeam(null, defaultTeamId));
return;
}
setLoading(true);
try {
const data = await fetchAgentProfile(itemId);
reset(buildForm(data));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服档案详情失败");
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, defaultTeamId, reset]);
useEffect(() => {
if (open) {
void loadOptions();
}
}, [loadOptions, open]);
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values));
}
const formId = "agent-edit-form";
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑客服档案" : "新建客服档案"}
size="lg"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form
id={formId}
onSubmit={handleSubmit(onFormSubmit)}
className="space-y-4"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.userId}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="userId"
render={({ field }) => (
<Popover
open={userSelectOpen}
onOpenChange={setUserSelectOpen}
>
<PopoverTrigger
render={
<Button
variant="outline"
role="combobox"
aria-expanded={userSelectOpen}
className="w-full justify-between font-normal"
/>
}
>
<span className="truncate">
{userOptions.find(
(option) => option.value === field.value,
)?.label ?? "请选择用户"}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popper-anchor-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder="搜索用户..." />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup>
{userOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
field.onChange(option.value);
setUserSelectOpen(false);
}}
>
<CheckIcon
className={`mr-2 size-4 ${
field.value === option.value
? "opacity-100"
: "opacity-0"
}`}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
/>
<FieldError errors={[errors.userId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.displayName}>
<FieldLabel htmlFor="agent-display-name"></FieldLabel>
<FieldContent>
<Input
id="agent-display-name"
placeholder="请输入展示名"
{...register("displayName")}
/>
<FieldError errors={[errors.displayName]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.agentCode}>
<FieldLabel htmlFor="agent-code"></FieldLabel>
<FieldContent>
<Input
id="agent-code"
placeholder="例如:A1001"
{...register("agentCode")}
/>
<FieldError errors={[errors.agentCode]} />
</FieldContent>
</Field>
<Field className="min-h-32">
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="avatar"
render={({ field }) => (
<ImageInput
value={field.value}
onChange={field.onChange}
disabled={saving}
prefix="avatar"
placeholder="上传头像"
className="size-16 rounded-full"
/>
)}
/>
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.serviceStatus}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="serviceStatus"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
modal={false}
>
<SelectTrigger className="w-full">
<SelectValue>{getStatusLabel(field.value)}</SelectValue>
</SelectTrigger>
<SelectContent>
{serviceStatusOptions.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.serviceStatus]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.maxConcurrentCount}>
<FieldLabel htmlFor="agent-max-concurrent-count">
</FieldLabel>
<FieldContent>
<Input
id="agent-max-concurrent-count"
type="number"
min={0}
{...register("maxConcurrentCount")}
/>
<FieldError errors={[errors.maxConcurrentCount]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.priorityLevel}>
<FieldLabel htmlFor="agent-priority-level"></FieldLabel>
<FieldContent>
<Input
id="agent-priority-level"
type="number"
step={1}
{...register("priorityLevel")}
/>
<FieldError errors={[errors.priorityLevel]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="autoAssignEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel>线</FieldLabel>
<FieldContent>
<Controller
control={control}
name="receiveOfflineMessage"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</FieldContent>
</Field>
</div>
<Field>
<FieldLabel htmlFor="agent-remark"></FieldLabel>
<FieldContent>
<Textarea
id="agent-remark"
rows={4}
placeholder="请输入备注"
{...register("remark")}
/>
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}
@@ -0,0 +1,371 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
import {
type AdminAgentTeam,
type CreateAdminAgentTeamPayload,
fetchAgentTeam,
fetchUsersAll,
type AdminUser,
} from "@/lib/api/admin";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Status, StatusLabels } from "@/lib/generated/enums";
import { getEnumOptions } from "@/lib/enums";
type TeamEditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateAdminAgentTeamPayload) => Promise<void>;
};
const statusOptions = getEnumOptions(StatusLabels)
.filter((option) => option.value !== Status.Deleted)
.map((option) => ({
value: String(option.value),
label: option.label,
}));
const emptyForm: EditForm = {
name: "",
leaderUserId: "0",
status: String(Status.Ok),
description: "",
remark: "",
};
const editFormSchema = z.object({
name: z.string().trim().min(1, "客服组名称不能为空"),
leaderUserId: z.string().trim().regex(/^\d+$/, "组长用户不合法"),
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: "请选择状态",
}),
description: z.string().trim(),
remark: z.string().trim(),
});
type EditForm = z.infer<typeof editFormSchema>;
const editFormResolver = zodResolver(editFormSchema as never) as Resolver<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>;
function buildForm(item: AdminAgentTeam | null): EditForm {
if (!item) {
return emptyForm;
}
return {
name: item.name,
leaderUserId: String(item.leaderUserId),
status: String(item.status),
description: item.description || "",
remark: item.remark || "",
};
}
function buildPayload(form: EditForm): CreateAdminAgentTeamPayload {
return {
name: form.name.trim(),
leaderUserId: Number(form.leaderUserId),
status: Number(form.status),
description: form.description.trim(),
remark: form.remark.trim(),
};
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: TeamEditDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<TeamEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
itemId={itemId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
) : null}
</Dialog>
);
}
type TeamEditDialogBodyProps = Omit<TeamEditDialogProps, "open">;
function TeamEditDialogBody({
saving,
itemId,
onOpenChange,
onSubmit,
}: TeamEditDialogBodyProps) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSelectOpen, setUserSelectOpen] = useState(false);
const [loading, setLoading] = useState(false);
const userOptions = users.map((user) => ({
value: String(user.id),
label: `${user.nickname || user.username} (${user.username})`,
}));
const loadUsers = useCallback(async () => {
try {
const data = await fetchUsersAll();
setUsers(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载用户选项失败");
}
}, []);
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
const {
control,
handleSubmit,
reset,
register,
formState: { errors },
} = form;
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm);
return;
}
setLoading(true);
try {
const data = await fetchAgentTeam(itemId);
reset(buildForm(data));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组详情失败");
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values));
}
return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑" : "新建"}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="agent-team-name"></FieldLabel>
<FieldContent>
<Input
id="agent-team-name"
placeholder="请输入客服组名称"
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.leaderUserId}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="leaderUserId"
render={({ field }) => (
<Popover open={userSelectOpen} onOpenChange={setUserSelectOpen}>
<PopoverTrigger
render={
<Button
variant="outline"
role="combobox"
aria-expanded={userSelectOpen}
className="w-full justify-between font-normal"
/>
}
>
<span className="truncate">
{field.value === "0"
? "暂不设置"
: userOptions.find((option) => option.value === field.value)?.label ?? "请选择组长"}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] p-0" align="start">
<Command>
<CommandInput placeholder="搜索用户..." />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup>
<CommandItem
value="暂不设置"
onSelect={() => {
field.onChange("0");
setUserSelectOpen(false);
}}
>
<CheckIcon
className={`mr-2 size-4 ${field.value === "0" ? "opacity-100" : "opacity-0"}`}
/>
</CommandItem>
{userOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
field.onChange(option.value);
setUserSelectOpen(false);
}}
>
<CheckIcon
className={`mr-2 size-4 ${
field.value === option.value ? "opacity-100" : "opacity-0"
}`}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
/>
<FieldError errors={[errors.leaderUserId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.status}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
modal={false}
>
<SelectTrigger className="w-full">
<SelectValue>
{statusOptions.find(
(item) => item.value === field.value,
)?.label ?? "请选择状态"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.status]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="agent-team-description"></FieldLabel>
<FieldContent>
<Input
id="agent-team-description"
placeholder="例如:负责售前咨询与线索转化"
{...register("description")}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="agent-team-remark"></FieldLabel>
<FieldContent>
<Textarea
id="agent-team-remark"
rows={4}
placeholder="请输入备注"
{...register("remark")}
/>
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
);
}
@@ -0,0 +1,287 @@
"use client";
import {
MoreHorizontalIcon,
Pencil,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
UsersRoundIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { EditDialog } from "./team-edit";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
createAgentTeam,
deleteAgentTeam,
fetchAgentTeams,
updateAgentTeam,
type AdminAgentTeam,
type CreateAdminAgentTeamPayload,
} from "@/lib/api/admin";
import { Status, StatusLabels } from "@/lib/generated/enums";
import { getEnumLabel } from "@/lib/enums";
import { cn } from "@/lib/utils";
type AgentTeamSidebarProps = {
selectedTeamId: number | null;
onSelectTeam: (team: AdminAgentTeam | null) => void;
onTeamsChange?: (teams: AdminAgentTeam[]) => void;
};
const statusTabs = [
{ value: "all", label: "全部" },
{ value: String(Status.Ok), label: StatusLabels[Status.Ok] },
{ value: String(Status.Disabled), label: StatusLabels[Status.Disabled] },
] as const;
export function AgentTeamSidebar({
selectedTeamId,
onSelectTeam,
onTeamsChange,
}: AgentTeamSidebarProps) {
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] =
useState<(typeof statusTabs)[number]["value"]>("all");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<AdminAgentTeam | null>(null);
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAgentTeams({ page: 1, limit: 200 });
setTeams(data);
onTeamsChange?.(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组失败");
} finally {
setLoading(false);
}
}, [onTeamsChange]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
if (selectedTeamId == null) {
return;
}
const matchedTeam =
teams.find((item) => item.id === selectedTeamId) ?? null;
if (matchedTeam) {
onSelectTeam(matchedTeam);
return;
}
if (!loading && teams.length > 0) {
onSelectTeam(teams[0]);
}
}, [loading, onSelectTeam, selectedTeamId, teams]);
const filteredTeams = useMemo(() => {
const output = keyword.trim().toLowerCase();
return teams.filter((item) => {
const matchedKeyword =
output.length === 0 ||
item.name.toLowerCase().includes(output) ||
item.description.toLowerCase().includes(output);
const matchedStatus =
statusFilter === "all" || String(item.status) === statusFilter;
return matchedKeyword && matchedStatus;
});
}, [keyword, statusFilter, teams]);
function openCreateDialog() {
setEditingItem(null);
setDialogOpen(true);
}
function openEditDialog(item: AdminAgentTeam) {
setEditingItem(item);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateAdminAgentTeamPayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateAgentTeam({ id: editingItem.id, ...payload });
toast.success(`已更新客服组:${editingItem.name}`);
} else {
await createAgentTeam(payload);
toast.success(`已创建客服组:${payload.name}`);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: AdminAgentTeam) {
setActionLoadingId(item.id);
try {
await deleteAgentTeam(item.id);
toast.success(`已删除客服组:${item.name}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组失败");
} finally {
setActionLoadingId(null);
}
}
return (
<>
<div className="flex h-full flex-col border-r bg-muted/10">
<div className="border-b px-3 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-medium"></div>
</div>
</div>
<div className="relative mt-3">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索客服组"
className="pl-9"
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{statusTabs.map((item) => (
<Button
key={item.value}
variant={statusFilter === item.value ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter(item.value)}
>
{item.label}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => void loadData()}
disabled={loading}
>
<RefreshCwIcon
className={cn("size-4", loading && "animate-spin")}
/>
</Button>
<Button size="icon-sm" onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="px-2 py-2">
{filteredTeams.map((item) => (
<div
key={item.id}
className={cn(
"group mt-1 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors hover:bg-accent",
selectedTeamId === item.id &&
"bg-accent text-accent-foreground",
)}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => onSelectTeam(item)}
>
<UsersRoundIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{item.name}
</span>
</span>
<Badge
variant={
item.status === Status.Ok ? "secondary" : "outline"
}
>
{getEnumLabel(StatusLabels, item.status as Status)}
</Badge>
</button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 group-hover:opacity-100"
/>
}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<Pencil />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
{!loading && filteredTeams.length === 0 ? (
<div className="px-2 py-10 text-center text-sm text-muted-foreground">
</div>
) : null}
</div>
</ScrollArea>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
);
}
+474
View File
@@ -0,0 +1,474 @@
"use client";
import {
MoreHorizontalIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
SearchIcon,
Trash2Icon,
UserCogIcon
} from "lucide-react";
import { useCallback, useEffect, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
createAgentProfile,
deleteAgentProfile,
fetchAgentProfiles,
updateAgentProfile,
type AdminAgentProfile,
type AdminAgentTeam,
type CreateAdminAgentProfilePayload,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
ServiceStatus,
ServiceStatusLabels,
} from "@/lib/generated/enums";
import { formatDateTime } from "@/lib/utils";
import { EditDialog } from "./_components/edit";
import { AgentTeamSidebar } from "./_components/team-sidebar";
const serviceStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(ServiceStatusLabels),
];
function getStatusLabel(value: number) {
return getEnumLabel(ServiceStatusLabels, value as ServiceStatus);
}
export default function DashboardAgentsPage() {
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [agentCodeInput, setAgentCodeInput] = useState("");
const [displayNameInput, setDisplayNameInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [agentCode, setAgentCode] = useState("");
const [displayName, setDisplayName] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<AdminAgentProfile | null>(
null,
);
const [result, setResult] = useState<PageResult<AdminAgentProfile>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAgentProfiles({
teamId: selectedTeam?.id,
agentCode: agentCode.trim() || undefined,
displayName: displayName.trim() || undefined,
serviceStatus: statusFilter === "all" ? undefined : statusFilter,
page,
limit,
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服档案失败");
} finally {
setLoading(false);
}
}, [agentCode, displayName, limit, page, selectedTeam?.id, statusFilter]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
setPage(1);
}, [selectedTeam?.id]);
useEffect(() => {
if (teams.length === 0) {
if (selectedTeam) {
setSelectedTeam(null);
}
return;
}
if (!selectedTeam) {
setSelectedTeam(teams[0]);
return;
}
const matchedTeam = teams.find((item) => item.id === selectedTeam.id);
if (!matchedTeam) {
setSelectedTeam(teams[0]);
return;
}
if (matchedTeam !== selectedTeam) {
setSelectedTeam(matchedTeam);
}
}, [selectedTeam, teams]);
function applyFilters() {
setAgentCode(agentCodeInput);
setDisplayName(displayNameInput);
setStatusFilter(statusFilterInput);
setPage(1);
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return;
}
setPage(nextPage);
}
function openCreateDialog() {
setEditingItem(null);
setDialogOpen(true);
}
function openEditDialog(item: AdminAgentProfile) {
setEditingItem(item);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateAdminAgentProfilePayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateAgentProfile({ id: editingItem.id, ...payload });
toast.success(`已更新客服档案:${editingItem.displayName}`);
} else {
await createAgentProfile(payload);
toast.success(`已创建客服档案:${payload.displayName}`);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服档案失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: AdminAgentProfile) {
setActionLoadingId(item.id);
try {
await deleteAgentProfile(item.id);
toast.success(`已删除客服档案:${item.displayName}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服档案失败");
} finally {
setActionLoadingId(null);
}
}
return (
<>
<div className="flex h-[calc(100vh-4rem)]">
<div
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
sidebarCollapsed ? "w-0" : "w-80"
}`}
>
<AgentTeamSidebar
selectedTeamId={selectedTeam?.id ?? null}
onSelectTeam={setSelectedTeam}
onTeamsChange={setTeams}
/>
</div>
<div className="relative shrink-0 bg-background">
<Button
variant="outline"
size="icon"
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => setSidebarCollapsed((value) => !value)}
aria-label={sidebarCollapsed ? "展开客服组列表" : "折叠客服组列表"}
>
{sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" />
) : (
<PanelLeftCloseIcon className="size-3.5" />
)}
</Button>
</div>
<div className="min-w-0 flex-1 p-4 lg:p-6">
<div className="flex h-full flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-lg font-semibold">
{selectedTeam ? selectedTeam.name : "客服档案"}
</div>
</div>
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-w-72">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={displayNameInput}
onChange={(event) =>
setDisplayNameInput(event.target.value)
}
onKeyDown={handleFilterKeyDown}
placeholder="按展示名筛选"
className="pl-9"
/>
</div>
<Input
value={agentCodeInput}
onChange={(event) => setAgentCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按客服工号筛选"
className="w-full xl:w-48"
/>
<Select
value={statusFilterInput}
onValueChange={(value) =>
setStatusFilterInput(value ?? "all")
}
>
<SelectTrigger className="w-full xl:w-36">
<SelectValue>
{serviceStatusOptions.find(
(item) => item.value === statusFilterInput,
)?.label ?? "全部状态"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{serviceStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
<SearchIcon />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
</div>
<div className="min-h-0 space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-10 items-center justify-center overflow-hidden rounded-2xl bg-muted">
{item.avatar ? (
<img
src={item.avatar}
alt={item.displayName}
className="size-full object-cover"
/>
) : (
<UserCogIcon className="size-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div className="font-medium">
{item.displayName}
</div>
<div className="text-xs text-muted-foreground">
{item.nickname ||
item.username ||
`用户#${item.userId}`}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.agentCode}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{getStatusLabel(item.serviceStatus)}
</Badge>
<div className="mt-2 text-sm text-muted-foreground">
{item.maxConcurrentCount} / {" "}
{item.priorityLevel}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
<Badge
variant={
item.autoAssignEnabled ? "secondary" : "outline"
}
>
{item.autoAssignEnabled
? "自动分配"
: "不自动分配"}
</Badge>
<Badge
variant={
item.receiveOfflineMessage
? "secondary"
: "outline"
}
>
{item.receiveOfflineMessage
? "离线接收"
: "离线不接收"}
</Badge>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
线{formatDateTime(item.lastOnlineAt)}
</div>
<div className="text-sm text-muted-foreground">
{formatDateTime(item.lastStatusAt)}
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(item)}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" />
}
aria-label={`更多操作 ${item.displayName}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40 min-w-40"
>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id
? "删除中..."
: "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-12 text-center text-muted-foreground"
>
{selectedTeam
? "当前客服组下没有匹配的客服档案"
: "没有匹配的客服档案"}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit);
setPage(1);
}}
/>
</div>
</div>
</div>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
defaultTeamId={selectedTeam?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,563 @@
"use client";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
BotMessageSquareIcon,
GripVerticalIcon,
MoreHorizontalIcon,
PlusIcon,
PowerIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react";
import {
useCallback,
useEffect,
useState,
type CSSProperties,
} from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
import { OptionCombobox } from "@/components/option-combobox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
createAIAgent,
deleteAIAgent,
fetchAIAgents,
updateAIAgent,
updateAIAgentSort,
updateAIAgentStatus,
type AIAgent,
type CreateAIAgentPayload,
type PageResult,
} from "@/lib/api/admin";
import {
IMConversationServiceModeLabels,
Status,
StatusLabels,
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { cn } from "@/lib/utils";
import { EditDialog } from "./_components/edit";
import { ButtonGroup } from "@/components/ui/button-group";
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
function getStatusLabel(value: string) {
return (
statusOptions.find((item) => item.value === value)?.label ?? "全部状态"
);
}
type SortableAIAgentRowProps = {
item: AIAgent;
disabled: boolean;
actionLoadingId: number | null;
openEditDialog: (item: AIAgent) => void;
handleToggleStatus: (item: AIAgent) => void;
handleDelete: (item: AIAgent) => void;
};
function SortableAIAgentRow({
item,
disabled,
actionLoadingId,
openEditDialog,
handleToggleStatus,
handleDelete,
}: SortableAIAgentRowProps) {
const knowledgeIds = item.knowledgeIds ?? [];
const knowledgeBaseNames = item.knowledgeBaseNames ?? [];
const skills = item.skills ?? [];
const directTools = item.directTools ?? [];
const directToolServerCodes = Array.from(
new Set(directTools.map((tool) => tool.serverCode).filter(Boolean)),
);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<TableRow
ref={setNodeRef}
style={style}
className={cn(
isDragging && "relative z-10 bg-muted/60 shadow-sm",
!disabled && "cursor-move",
)}
>
<TableCell className="w-14">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon className="size-4 text-muted-foreground" />
</Button>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-muted">
<BotMessageSquareIcon className="size-4" />
</div>
<div>
<div className="font-medium">{item.name}</div>
</div>
</div>
</TableCell>
<TableCell>
{item.aiConfigName || "-"}
</TableCell>
<TableCell>
{getEnumLabel(
IMConversationServiceModeLabels,
item.serviceMode as keyof typeof IMConversationServiceModeLabels,
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{knowledgeIds.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
) : (
knowledgeBaseNames.map((name, index) => (
<Badge key={knowledgeIds[index] ?? `${item.id}-${index}`} variant="secondary">
{name}
</Badge>
))
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{skills.length === 0 ? (
<span className="text-sm text-muted-foreground">RAG</span>
) : (
skills.map((skill) => (
<Badge key={skill.id} variant="outline">
{skill.name}
</Badge>
))
)}
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
<Badge variant="secondary">{skills.length} Skills</Badge>
<Badge variant="secondary">{directTools.length} Tools</Badge>
</div>
<div className="flex flex-wrap gap-1">
{directToolServerCodes.length === 0 ? (
<span className="text-sm text-muted-foreground"> MCP Server</span>
) : (
directToolServerCodes.map((serverCode) => (
<Badge key={serverCode} variant="outline">
{serverCode}
</Badge>
))
)}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Switch
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
/>
<Badge
variant={item.status === Status.Ok ? "default" : "secondary"}
>
{getStatusLabel(String(item.status))}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(item)}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" className="ml-auto" />
}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={actionLoadingId === item.id}
onClick={() => void handleToggleStatus(item)}
>
<PowerIcon className="size-4" />
{item.status === Status.Ok ? "停用" : "启用"}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
disabled={actionLoadingId === item.id}
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
);
}
export default function DashboardAIAgentsPage() {
const [nameInput, setNameInput] = useState("");
const [statusInput, setStatusInput] = useState("all");
const [name, setName] = useState("");
const [status, setStatus] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
const [sorting, setSorting] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [result, setResult] = useState<PageResult<AIAgent>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAIAgents({
name: name.trim() || undefined,
status: status === "all" ? undefined : status,
page,
limit,
});
setResult(data);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 AI Agent 失败",
);
} finally {
setLoading(false);
}
}, [limit, name, page, status]);
useEffect(() => {
void loadData();
}, [loadData]);
function applyFilters() {
setName(nameInput);
setStatus(statusInput);
setPage(1);
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
function openCreateDialog() {
setEditingItemId(null);
setDialogOpen(true);
}
function openEditDialog(item: AIAgent) {
setEditingItemId(item.id);
setDialogOpen(true);
}
async function handleSubmit(payload: CreateAIAgentPayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItemId) {
await updateAIAgent({ id: editingItemId, ...payload });
toast.success(`已更新 AI Agent${payload.name}`);
} else {
const created = await createAIAgent(payload);
toast.success(`已创建 AI Agent${created.name}`);
}
setDialogOpen(false);
setEditingItemId(null);
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存 AI Agent 失败",
);
} finally {
setSaving(false);
}
}
async function handleToggleStatus(item: AIAgent) {
setActionLoadingId(item.id);
try {
const nextStatus =
item.status === Status.Ok ? Status.Disabled : Status.Ok;
await updateAIAgentStatus(item.id, nextStatus);
toast.success(
`${nextStatus === Status.Ok ? "启用" : "停用"}${item.name}`,
);
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "更新 AI Agent 状态失败",
);
} finally {
setActionLoadingId(null);
}
}
async function handleDelete(item: AIAgent) {
setActionLoadingId(item.id);
try {
await deleteAIAgent(item.id);
toast.success(`已删除 AI Agent${item.name}`);
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "删除 AI Agent 失败",
);
} finally {
setActionLoadingId(null);
}
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id || sorting) {
return;
}
const previousResults = result.results;
const oldIndex = previousResults.findIndex((item) => item.id === active.id);
const newIndex = previousResults.findIndex((item) => item.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const nextResults = arrayMove(previousResults, oldIndex, newIndex);
setResult((current) => ({
...current,
results: nextResults,
}));
setSorting(true);
try {
await updateAIAgentSort(nextResults.map((item) => item.id));
toast.success("AI Agent 排序已更新");
await loadData();
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}));
toast.error(error instanceof Error ? error.message : "更新排序失败");
} finally {
setSorting(false);
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<Input
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选"
className="w-full xl:w-56"
/>
<div className="w-full xl:w-52">
<OptionCombobox
value={statusInput}
options={statusOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
onChange={setStatusInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button
variant="outline"
onClick={() => void loadData()}
disabled={loading}
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
AI Agent
</Button>
</div>
<div className="rounded-xl border bg-card">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-14"></TableHead>
<TableHead>Agent</TableHead>
<TableHead>AI配置</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Skills</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[88px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className="py-12 text-center text-muted-foreground"
>
{loading ? "正在加载 AI Agent..." : "暂无 AI Agent"}
</TableCell>
</TableRow>
) : null}
<SortableContext
items={result.results.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{result.results.map((item) => (
<SortableAIAgentRow
key={item.id}
item={item}
disabled={sorting}
actionLoadingId={actionLoadingId}
openEditDialog={openEditDialog}
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
/>
))}
</SortableContext>
</TableBody>
</Table>
</DndContext>
<div className="border-t px-4 py-3">
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
onPageChange={(nextPage) => setPage(nextPage)}
onLimitChange={(nextLimit) => {
setLimit(nextLimit);
setPage(1);
}}
/>
</div>
</div>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItemId}
onOpenChange={setDialogOpen}
onSubmit={handleSubmit}
/>
</>
);
}
@@ -0,0 +1,461 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { type AIConfig, type CreateAIConfigPayload, fetchAIConfig } from "@/lib/api/admin"
import {
AIModelType,
AIModelTypeLabels,
AIProvider,
AIProviderLabels,
} from "@/lib/generated/enums"
import { getEnumOptions } from "@/lib/enums"
import { OptionCombobox } from "./option-combobox"
type AIConfigEditDialogProps = {
open: boolean
saving: boolean
itemId: number | null
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateAIConfigPayload) => Promise<void>
}
const providerOptions = getEnumOptions(AIProviderLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))
const modelTypeOptions = getEnumOptions(AIModelTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))
const emptyForm: EditForm = {
name: "",
provider: AIProvider.OpenAI,
baseUrl: "",
apiKey: "",
modelType: AIModelType.LLM,
modelName: "",
dimension: "0",
maxContextTokens: "0",
maxOutputTokens: "0",
timeoutMs: "120000",
maxRetryCount: "0",
rpmLimit: "0",
tpmLimit: "0",
remark: "",
}
const aiConfigFormSchema = z.object({
name: z.string().trim().min(1, "配置名称不能为空"),
provider: z.string().trim().min(1, "供应商不能为空"),
baseUrl: z.string().trim().min(1, "基础地址不能为空"),
apiKey: z.string().trim(),
modelType: z.string().trim().min(1, "模型类型不能为空"),
modelName: z.string().trim().min(1, "模型名称不能为空"),
dimension: z.string().trim().regex(/^\d+$/, "向量维度必须是大于等于 0 的整数"),
maxContextTokens: z.string().trim().regex(/^\d+$/, "最大上下文 Token 必须是大于等于 0 的整数"),
maxOutputTokens: z.string().trim().regex(/^\d+$/, "最大输出 Token 必须是大于等于 0 的整数"),
timeoutMs: z.string().trim().regex(/^\d+$/, "超时时间必须是大于等于 0 的整数"),
maxRetryCount: z.string().trim().regex(/^\d+$/, "最大重试次数必须是大于等于 0 的整数"),
rpmLimit: z.string().trim().regex(/^\d+$/, "RPM 限制必须是大于等于 0 的整数"),
tpmLimit: z.string().trim().regex(/^\d+$/, "TPM 限制必须是大于等于 0 的整数"),
remark: z.string().trim(),
})
type EditForm = z.infer<typeof aiConfigFormSchema>
const editFormResolver = zodResolver(aiConfigFormSchema as never) as Resolver<
z.input<typeof aiConfigFormSchema>,
undefined,
z.output<typeof aiConfigFormSchema>
>
function buildForm(item: AIConfig | null): EditForm {
if (!item) {
return emptyForm
}
return {
name: item.name,
provider: item.provider,
baseUrl: item.baseUrl,
apiKey: item.apiKey,
modelType: item.modelType,
modelName: item.modelName,
dimension: String(item.dimension),
maxContextTokens: String(item.maxContextTokens),
maxOutputTokens: String(item.maxOutputTokens),
timeoutMs: String(item.timeoutMs),
maxRetryCount: String(item.maxRetryCount),
rpmLimit: String(item.rpmLimit),
tpmLimit: String(item.tpmLimit),
remark: item.remark ?? "",
}
}
function buildPayload(form: EditForm): CreateAIConfigPayload {
return {
name: form.name.trim(),
provider: form.provider,
baseUrl: form.baseUrl.trim(),
apiKey: form.apiKey.trim(),
modelType: form.modelType,
modelName: form.modelName.trim(),
dimension: Number(form.dimension),
maxContextTokens: Number(form.maxContextTokens),
maxOutputTokens: Number(form.maxOutputTokens),
timeoutMs: Number(form.timeoutMs),
maxRetryCount: Number(form.maxRetryCount),
rpmLimit: Number(form.rpmLimit),
tpmLimit: Number(form.tpmLimit),
remark: form.remark.trim(),
}
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: AIConfigEditDialogProps) {
if (!open) {
return null
}
return (
<AIConfigEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
saving={saving}
itemId={itemId}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)
}
type AIConfigEditDialogBodyProps = AIConfigEditDialogProps
function AIConfigEditDialogBody({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: AIConfigEditDialogBodyProps) {
const formId = "ai-config-edit-form"
const [loading, setLoading] = useState(false)
const form = useForm<
z.input<typeof aiConfigFormSchema>,
undefined,
z.output<typeof aiConfigFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
reset,
register,
watch,
formState: { errors },
} = form
const modelType = watch("modelType")
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm)
return
}
setLoading(true)
try {
const data = await fetchAIConfig(itemId)
reset(buildForm(data))
} catch (error) {
console.error("Failed to load AI config:", error)
} finally {
setLoading(false)
}
}
void loadDetail()
}, [itemId, reset])
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values))
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑 AI 配置" : "新建 AI 配置"}
size="xl"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ai-config-name"></FieldLabel>
<FieldContent>
<Input
id="ai-config-name"
placeholder="例如:OpenAI 主回答模型"
aria-invalid={!!errors.name}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.provider}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="provider"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={providerOptions}
placeholder="请选择供应商"
searchPlaceholder="搜索供应商"
emptyText="未找到供应商"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.provider]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.modelType}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="modelType"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={modelTypeOptions}
placeholder="请选择模型类型"
searchPlaceholder="搜索模型类型"
emptyText="未找到模型类型"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.modelType]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.baseUrl}>
<FieldLabel htmlFor="ai-config-base-url">Base URL</FieldLabel>
<FieldContent>
<Input
id="ai-config-base-url"
placeholder="例如:https://api.openai.com/v1"
aria-invalid={!!errors.baseUrl}
{...register("baseUrl")}
/>
<FieldError errors={[errors.baseUrl]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.apiKey}>
<FieldLabel htmlFor="ai-config-api-key">API Key</FieldLabel>
<FieldContent>
<Input
id="ai-config-api-key"
type="password"
placeholder="请输入 API Key"
aria-invalid={!!errors.apiKey}
{...register("apiKey")}
/>
<FieldError errors={[errors.apiKey]} />
</FieldContent>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.modelName}>
<FieldLabel htmlFor="ai-config-model-name"></FieldLabel>
<FieldContent>
<Input
id="ai-config-model-name"
placeholder="例如:gpt-4o-mini"
aria-invalid={!!errors.modelName}
{...register("modelName")}
/>
<FieldError errors={[errors.modelName]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.dimension}>
<FieldLabel htmlFor="ai-config-dimension"></FieldLabel>
<FieldContent>
<Input
id="ai-config-dimension"
type="number"
min={0}
step={1}
disabled={modelType !== AIModelType.Embedding}
aria-invalid={!!errors.dimension}
{...register("dimension")}
/>
<FieldError errors={[errors.dimension]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.maxContextTokens}>
<FieldLabel htmlFor="ai-config-max-context"> Token</FieldLabel>
<FieldContent>
<Input
id="ai-config-max-context"
type="number"
min={0}
step={1}
aria-invalid={!!errors.maxContextTokens}
{...register("maxContextTokens")}
/>
<FieldError errors={[errors.maxContextTokens]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.maxOutputTokens}>
<FieldLabel htmlFor="ai-config-max-output"> Token</FieldLabel>
<FieldContent>
<Input
id="ai-config-max-output"
type="number"
min={0}
step={1}
aria-invalid={!!errors.maxOutputTokens}
{...register("maxOutputTokens")}
/>
<FieldError errors={[errors.maxOutputTokens]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.timeoutMs}>
<FieldLabel htmlFor="ai-config-timeout"> (ms)</FieldLabel>
<FieldContent>
<Input
id="ai-config-timeout"
type="number"
min={0}
step={1}
aria-invalid={!!errors.timeoutMs}
{...register("timeoutMs")}
/>
<FieldError errors={[errors.timeoutMs]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.maxRetryCount}>
<FieldLabel htmlFor="ai-config-retry"></FieldLabel>
<FieldContent>
<Input
id="ai-config-retry"
type="number"
min={0}
step={1}
aria-invalid={!!errors.maxRetryCount}
{...register("maxRetryCount")}
/>
<FieldError errors={[errors.maxRetryCount]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.rpmLimit}>
<FieldLabel htmlFor="ai-config-rpm">RPM </FieldLabel>
<FieldContent>
<Input
id="ai-config-rpm"
type="number"
min={0}
step={1}
aria-invalid={!!errors.rpmLimit}
{...register("rpmLimit")}
/>
<FieldError errors={[errors.rpmLimit]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.tpmLimit}>
<FieldLabel htmlFor="ai-config-tpm">TPM </FieldLabel>
<FieldContent>
<Input
id="ai-config-tpm"
type="number"
min={0}
step={1}
aria-invalid={!!errors.tpmLimit}
{...register("tpmLimit")}
/>
<FieldError errors={[errors.tpmLimit]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="ai-config-remark"></FieldLabel>
<FieldContent>
<Textarea
id="ai-config-remark"
placeholder="记录用途、费用、限制说明等"
rows={3}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
)
}
@@ -0,0 +1,90 @@
"use client"
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
export type ComboboxOption = {
value: string
label: string
}
type OptionComboboxProps = {
value: string
options: ComboboxOption[]
placeholder: string
searchPlaceholder?: string
emptyText?: string
disabled?: boolean
onChange: (value: string) => void
}
export function OptionCombobox({
value,
options,
placeholder,
searchPlaceholder = "请输入关键字搜索",
emptyText = "没有可选项",
disabled = false,
onChange,
}: OptionComboboxProps) {
const selectedLabel =
options.find((option) => option.value === value)?.label ?? placeholder
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
disabled={disabled}
/>
}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => onChange(option.value)}
>
<CheckIcon
className={cn(
"mr-2 size-4",
option.value === value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,685 @@
"use client";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
GripVerticalIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon
} from "lucide-react";
import { useCallback, useEffect, useState, type CSSProperties } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
createAIConfig,
deleteAIConfig,
fetchAIConfigs,
updateAIConfig,
updateAIConfigSort,
updateAIConfigStatus,
type AIConfig,
type CreateAIConfigPayload,
type PageResult,
} from "@/lib/api/admin";
import {
AIModelType,
AIModelTypeLabels,
AIProvider,
AIProviderLabels,
Status,
StatusLabels
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { cn } from "@/lib/utils";
import { EditDialog } from "./_components/edit";
import { OptionCombobox } from "./_components/option-combobox";
const listStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
const providerFilterOptions = [
{ value: "all", label: "全部供应商" },
...getEnumOptions(AIProviderLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
const modelTypeFilterOptions = [
{ value: "all", label: "全部类型" },
...getEnumOptions(AIModelTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
function maskAPIKey(value: string) {
const text = value.trim();
if (!text) {
return "-";
}
if (text.length <= 8) {
return "****";
}
return `${text.slice(0, 4)}****${text.slice(-4)}`;
}
type SortableAIConfigRowProps = {
item: AIConfig;
disabled: boolean;
actionLoadingId: number | null;
openEditDialog: (item: AIConfig) => void;
handleToggleStatus: (item: AIConfig) => void;
handleDelete: (item: AIConfig) => void;
};
function SortableAIConfigRow({
item,
disabled,
actionLoadingId,
openEditDialog,
handleToggleStatus,
handleDelete,
}: SortableAIConfigRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<TableRow
ref={setNodeRef}
style={style}
className={cn(
isDragging && "relative z-10 bg-muted/60 shadow-sm",
!disabled && "cursor-move",
)}
>
<TableCell className="w-14">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon className="size-4 text-muted-foreground" />
</Button>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm font-medium">{item.name}</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{getEnumLabel(
AIProviderLabels,
item.provider as AIProvider,
)}
</Badge>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant="secondary">
{getEnumLabel(
AIModelTypeLabels,
item.modelType as AIModelType,
)}
</Badge>
<div className="text-sm">{item.modelName}</div>
{item.dimension > 0 && (
<div className="text-xs text-muted-foreground">
{item.dimension}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="line-clamp-1">{item.baseUrl}</div>
<div className="text-xs text-muted-foreground">
Key: {maskAPIKey(item.apiKey)}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-xs text-muted-foreground">
<div> {item.maxContextTokens || 0}</div>
<div> {item.maxOutputTokens || 0}</div>
<div>
{item.timeoutMs}ms / {item.maxRetryCount}
</div>
<div>
RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Switch
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
/>
<Badge
variant={
item.status === Status.Ok ? "default" : "outline"
}
>
{getEnumLabel(
StatusLabels,
item.status as keyof typeof StatusLabels,
)}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(item)}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
disabled={
item.status === Status.Ok ||
actionLoadingId === item.id
}
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{item.status === Status.Ok
? "启用中不可删"
: actionLoadingId === item.id
? "删除中..."
: "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
);
}
export default function DashboardAIConfigsPage() {
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [providerFilterInput, setProviderFilterInput] = useState("all");
const [modelTypeFilterInput, setModelTypeFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [providerFilter, setProviderFilter] = useState("all");
const [modelTypeFilter, setModelTypeFilter] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
const [sorting, setSorting] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<AIConfig | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingItem, setDeletingItem] = useState<AIConfig | null>(null);
const [result, setResult] = useState<PageResult<AIConfig>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAIConfigs({
name: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
provider: providerFilter === "all" ? undefined : providerFilter,
modelType: modelTypeFilter === "all" ? undefined : modelTypeFilter,
page,
limit,
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI 配置失败");
} finally {
setLoading(false);
}
}, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit]);
useEffect(() => {
void loadData();
}, [loadData]);
function applyFilters() {
setKeyword(keywordInput);
setStatusFilter(statusFilterInput);
setProviderFilter(providerFilterInput);
setModelTypeFilter(modelTypeFilterInput);
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);
}
function handleLimitChange(nextLimit: number) {
if (nextLimit <= 0 || nextLimit === limit) {
return;
}
setLimit(nextLimit);
setPage(1);
}
function openCreateDialog() {
setEditingItem(null);
setDialogOpen(true);
}
function openEditDialog(item: AIConfig) {
setEditingItem(item);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateAIConfigPayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateAIConfig({ id: editingItem.id, ...payload });
toast.success(`已更新 AI 配置:${editingItem.name}`);
} else {
await createAIConfig(payload);
toast.success(`已创建 AI 配置:${payload.name}`);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存 AI 配置失败");
} finally {
setSaving(false);
}
}
async function handleToggleStatus(item: AIConfig) {
setActionLoadingId(item.id);
try {
const nextStatus =
item.status === Status.Ok
? Status.Disabled
: Status.Ok;
await updateAIConfigStatus(item.id, nextStatus);
toast.success(
`${nextStatus === Status.Ok ? "启用" : "禁用"}${item.name}`,
);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败");
} finally {
setActionLoadingId(null);
}
}
async function handleDelete(item: AIConfig) {
if (item.status === Status.Ok) {
toast.error("启用中的 AI 配置不允许删除");
return;
}
setDeletingItem(item);
setDeleteDialogOpen(true);
}
async function handleConfirmDelete() {
if (!deletingItem) {
return;
}
const item = deletingItem;
setActionLoadingId(item.id);
try {
await deleteAIConfig(item.id);
toast.success(`已删除 AI 配置:${item.name}`);
setDeleteDialogOpen(false);
setDeletingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除 AI 配置失败");
} finally {
setActionLoadingId(null);
}
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id || sorting) {
return;
}
const previousResults = result.results;
const oldIndex = previousResults.findIndex((item) => item.id === active.id);
const newIndex = previousResults.findIndex((item) => item.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const nextResults = arrayMove(previousResults, oldIndex, newIndex);
setResult((current) => ({
...current,
results: nextResults,
}));
setSorting(true);
try {
await updateAIConfigSort(nextResults.map((item) => item.id));
toast.success("AI 配置排序已更新");
await loadData();
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}));
toast.error(error instanceof Error ? error.message : "更新排序失败");
} finally {
setSorting(false);
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-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="按配置名称筛选"
className="pl-9"
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={modelTypeFilterInput}
options={modelTypeFilterOptions}
placeholder="全部类型"
searchPlaceholder="搜索模型类型"
emptyText="未找到模型类型"
onChange={setModelTypeFilterInput}
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={providerFilterInput}
options={providerFilterOptions}
placeholder="全部供应商"
searchPlaceholder="搜索供应商"
emptyText="未找到供应商"
onChange={setProviderFilterInput}
/>
</div>
<div className="w-full xl:w-32">
<OptionCombobox
value={statusFilterInput}
options={listStatusOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
onChange={setStatusFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button
variant="outline"
onClick={() => void loadData()}
disabled={loading}
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
<div className="rounded-2xl border bg-card">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-14"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={8}
className="py-10 text-center text-muted-foreground"
>
AI ...
</TableCell>
</TableRow>
) : result.results.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="py-10 text-center text-muted-foreground"
>
AI
</TableCell>
</TableRow>
) : (
<SortableContext
items={result.results.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{result.results.map((item) => (
<SortableAIConfigRow
key={item.id}
item={item}
disabled={sorting}
actionLoadingId={actionLoadingId}
openEditDialog={openEditDialog}
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
/>
))}
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
</div>
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
<Dialog
open={deleteDialogOpen}
onOpenChange={(open) => {
if (actionLoadingId) {
return;
}
setDeleteDialogOpen(open);
if (!open) {
setDeletingItem(null);
}
}}
>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle> AI </DialogTitle>
<DialogDescription>
{deletingItem
? `确认删除“${deletingItem.name}”吗?此操作不可撤销。`
: "此操作不可撤销。"}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={!!actionLoadingId}
onClick={() => {
setDeleteDialogOpen(false);
setDeletingItem(null);
}}
>
</Button>
<Button
type="button"
variant="destructive"
disabled={!!actionLoadingId}
onClick={() => void handleConfirmDelete()}
>
{actionLoadingId ? "删除中..." : "确认删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,295 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm, useWatch } from "react-hook-form"
import { z } from "zod/v4"
import { OptionCombobox } from "@/components/option-combobox"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
type AIAgent,
type AdminChannel,
type CreateAdminChannelPayload,
fetchAIAgentsAll,
fetchChannel,
} from "@/lib/api/admin"
type ChannelFormDialogProps = {
open: boolean
saving: boolean
itemId: number | null
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateAdminChannelPayload) => Promise<void>
}
const channelTypeOptions = [
{ value: "web", label: "Web 站点" },
{ value: "wxwork_kf", label: "企业微信客服" },
] as const
const schema = z.object({
channelType: z.enum(["web", "wxwork_kf"], "请选择渠道类型"),
aiAgentId: z.string().trim().regex(/^\d+$/, "请选择 AI Agent"),
name: z.string().trim().min(1, "渠道名称不能为空"),
openKfId: z.string().trim(),
remark: z.string().trim(),
})
type EditForm = z.infer<typeof schema>
const resolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
const emptyForm: EditForm = {
channelType: "web",
aiAgentId: "",
name: "",
openKfId: "",
remark: "",
}
function parseOpenKfId(configJson: string): string {
if (!configJson.trim()) {
return ""
}
try {
const parsed = JSON.parse(configJson) as { openKfId?: string }
return typeof parsed.openKfId === "string" ? parsed.openKfId.trim() : ""
} catch {
return ""
}
}
function buildForm(item: AdminChannel | null): EditForm {
if (!item) {
return emptyForm
}
return {
channelType: item.channelType === "wxwork_kf" ? "wxwork_kf" : "web",
aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "",
name: item.name,
openKfId: parseOpenKfId(item.configJson),
remark: item.remark || "",
}
}
function buildPayload(form: EditForm, status: number): CreateAdminChannelPayload {
const channelType = form.channelType
const configJson =
channelType === "wxwork_kf"
? JSON.stringify({ openKfId: form.openKfId.trim() })
: ""
return {
channelType,
aiAgentId: Number(form.aiAgentId),
name: form.name.trim(),
configJson,
status,
remark: form.remark.trim(),
}
}
type ChannelFormBodyProps = Omit<ChannelFormDialogProps, "open">
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: ChannelFormDialogProps) {
if (!open) {
return null
}
return (
<ChannelFormBody
key={itemId ? `edit-${itemId}` : "create"}
itemId={itemId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)
}
function ChannelFormBody({
saving,
itemId,
onOpenChange,
onSubmit,
}: ChannelFormBodyProps) {
const formId = "channel-edit-form"
const [loading, setLoading] = useState(false)
const [aiAgents, setAIAgents] = useState<AIAgent[]>([])
const [currentStatus, setCurrentStatus] = useState(0)
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
resolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
register,
reset,
formState: { errors },
} = form
const channelType = useWatch({ control, name: "channelType" })
useEffect(() => {
async function loadAIAgents() {
try {
const data = await fetchAIAgentsAll({ status: 1 })
setAIAgents(data)
} catch (error) {
console.error("Failed to load AI agents:", error)
}
}
void loadAIAgents()
}, [])
useEffect(() => {
async function loadDetail() {
if (!itemId) {
setCurrentStatus(0)
reset(emptyForm)
return
}
setLoading(true)
try {
const data = await fetchChannel(itemId)
setCurrentStatus(data.status)
reset(buildForm(data))
} catch (error) {
console.error("Failed to load channel:", error)
} finally {
setLoading(false)
}
}
void loadDetail()
}, [itemId, reset])
const aiAgentOptions = aiAgents.map((item) => ({
value: String(item.id),
label: item.name,
}))
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, currentStatus))
}
return (
<ProjectDialog
open={true}
onOpenChange={onOpenChange}
title={itemId ? "编辑渠道" : "新建渠道"}
size="lg"
allowFullscreen
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.channelType}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="channelType"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={[...channelTypeOptions]}
placeholder="请选择渠道类型"
searchPlaceholder="搜索渠道类型"
emptyText="未找到渠道类型"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.channelType]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.aiAgentId}>
<FieldLabel> Agent</FieldLabel>
<FieldContent>
<Controller
control={control}
name="aiAgentId"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={aiAgentOptions}
placeholder="请选择 AI Agent"
searchPlaceholder="搜索 AI Agent"
emptyText="未找到 AI Agent"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.aiAgentId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="channel-name"></FieldLabel>
<FieldContent>
<Input id="channel-name" {...register("name")} />
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
{channelType === "wxwork_kf" ? (
<Field data-invalid={!!errors.openKfId}>
<FieldLabel htmlFor="channel-open-kf-id">OpenKfID</FieldLabel>
<FieldContent>
<Input id="channel-open-kf-id" {...register("openKfId")} />
<FieldError errors={[errors.openKfId]} />
</FieldContent>
</Field>
) : null}
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="channel-remark"></FieldLabel>
<FieldContent>
<Textarea id="channel-remark" rows={3} {...register("remark")} />
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
)
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import {
Building2Icon,
MessageSquareMoreIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "sonner"
import {
createChannel,
deleteChannel,
fetchChannels,
updateChannel,
updateChannelStatus,
type AdminChannel,
type CreateAdminChannelPayload,
type PageResult,
} from "@/lib/api/admin"
import { OptionCombobox } from "@/components/option-combobox"
import { EditDialog } from "./_components/edit"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { ListPagination } from "@/components/list-pagination"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { ButtonGroup } from "@/components/ui/button-group"
import { Switch } from "@/components/ui/switch"
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
] as const
const channelTypeOptions = [
{ value: "all", label: "全部类型" },
{ value: "web", label: "Web 站点" },
{ value: "wxwork_kf", label: "企业微信客服" },
] as const
function getChannelTypeLabel(channelType: string) {
if (channelType === "wxwork_kf") {
return "企业微信客服"
}
return "Web 站点"
}
function ChannelIcon({ channelType }: { channelType: string }) {
if (channelType === "wxwork_kf") {
return <MessageSquareMoreIcon className="size-4" />
}
return <Building2Icon className="size-4" />
}
export default function DashboardChannelsPage() {
const [nameInput, setNameInput] = useState("")
const [channelIdInput, setChannelIdInput] = useState("")
const [channelTypeInput, setChannelTypeInput] = useState("all")
const [statusInput, setStatusInput] = useState("all")
const [name, setName] = useState("")
const [channelId, setChannelId] = useState("")
const [channelType, setChannelType] = useState("all")
const [status, setStatus] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<AdminChannel | null>(null)
const [result, setResult] = useState<PageResult<AdminChannel>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchChannels({
name: name.trim() || undefined,
channelId: channelId.trim() || undefined,
channelType: channelType === "all" ? undefined : channelType,
status: status === "all" ? undefined : status,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载接入渠道失败")
} finally {
setLoading(false)
}
}, [channelId, channelType, limit, name, page, status])
useEffect(() => {
void loadData()
}, [loadData])
function applyFilters() {
setName(nameInput)
setChannelId(channelIdInput)
setChannelType(channelTypeInput)
setStatus(statusInput)
setPage(1)
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
function openCreateDialog() {
setEditingItem(null)
setDialogOpen(true)
}
function openEditDialog(item: AdminChannel) {
setEditingItem(item)
setDialogOpen(true)
}
async function handleSubmit(payload: CreateAdminChannelPayload) {
if (saving) {
return
}
setSaving(true)
try {
if (editingItem) {
await updateChannel({ id: editingItem.id, ...payload })
toast.success(`已更新接入渠道:${payload.name}`)
} else {
const created = await createChannel(payload)
toast.success(`已创建接入渠道:${created.name}`)
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存接入渠道失败")
} finally {
setSaving(false)
}
}
async function handleToggleStatus(item: AdminChannel) {
setActionLoadingId(item.id)
try {
const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok
await updateChannelStatus(item.id, nextStatus)
toast.success(`${nextStatus === Status.Ok ? "启用" : "禁用"}${item.name}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新渠道状态失败")
} finally {
setActionLoadingId(null)
}
}
async function handleDelete(item: AdminChannel) {
setActionLoadingId(item.id)
try {
await deleteChannel(item.id)
toast.success(`已删除接入渠道:${item.name}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除接入渠道失败")
} finally {
setActionLoadingId(null)
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:flex-wrap xl:items-center xl:justify-end">
<Input
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按渠道名称筛选"
className="w-full xl:w-56"
/>
<div className="relative min-w-72">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={channelIdInput}
onChange={(event) => setChannelIdInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按 channelId 筛选"
className="pl-9"
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={channelTypeInput}
options={[...channelTypeOptions]}
placeholder="全部类型"
searchPlaceholder="搜索渠道类型"
emptyText="未找到渠道类型"
onChange={setChannelTypeInput}
/>
</div>
<div className="w-full xl:w-36">
<OptionCombobox
value={statusInput}
options={[...statusOptions]}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
onChange={setStatusInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
<div className="rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>ChannelID</TableHead>
<TableHead> Agent</TableHead>
<TableHead></TableHead>
<TableHead className="w-[88px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
{loading ? "正在加载接入渠道..." : "暂无接入渠道"}
</TableCell>
</TableRow>
) : null}
{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">
<ChannelIcon channelType={item.channelType} />
</div>
<div>
<div className="font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">{getChannelTypeLabel(item.channelType)}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{getChannelTypeLabel(item.channelType)}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{item.channelId || "-"}</TableCell>
<TableCell>{item.aiAgentName || "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Switch
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
/>
<Badge variant={item.status === Status.Ok ? "default" : "outline"}>
{getEnumLabel(StatusLabels, item.status as Status)}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" className="ml-auto" />}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
disabled={actionLoadingId === item.id}
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="border-t px-4 py-3">
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
onPageChange={(nextPage) => setPage(nextPage)}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
</div>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={setDialogOpen}
onSubmit={handleSubmit}
/>
</>
)
}
@@ -0,0 +1,223 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import type { Resolver } from "react-hook-form"
import { useForm } from "react-hook-form"
import { z } from "zod/v4"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
fetchCompany,
type AdminCompany,
type CreateAdminCompanyPayload,
} from "@/lib/api/company"
type CompanyEditDialogProps = {
open: boolean
saving: boolean
itemId: number | null
initialValues?: Partial<CreateAdminCompanyPayload>
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateAdminCompanyPayload) => Promise<void>
}
const companyFormSchema = z.object({
name: z.string().trim().min(1, "公司名称不能为空"),
code: z.string().trim(),
remark: z.string().trim(),
})
type EditForm = z.infer<typeof companyFormSchema>
const editFormResolver = zodResolver(companyFormSchema as never) as Resolver<
z.input<typeof companyFormSchema>,
undefined,
z.output<typeof companyFormSchema>
>
const emptyForm: EditForm = {
name: "",
code: "",
remark: "",
}
function buildForm(item: AdminCompany | null): EditForm {
if (!item) {
return emptyForm
}
return {
name: item.name,
code: item.code,
remark: item.remark,
}
}
function buildPayload(form: EditForm): CreateAdminCompanyPayload {
return {
name: form.name.trim(),
code: form.code.trim(),
remark: form.remark.trim(),
}
}
function buildInitialForm(initialValues?: Partial<CreateAdminCompanyPayload>): EditForm {
return {
name: initialValues?.name?.trim() ?? "",
code: initialValues?.code?.trim() ?? "",
remark: initialValues?.remark?.trim() ?? "",
}
}
export function EditDialog({
open,
saving,
itemId,
initialValues,
onOpenChange,
onSubmit,
}: CompanyEditDialogProps) {
if (!open) {
return null
}
return (
<CompanyEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
saving={saving}
itemId={itemId}
initialValues={initialValues}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)
}
type CompanyEditDialogBodyProps = CompanyEditDialogProps
function CompanyEditDialogBody({
open,
saving,
itemId,
initialValues,
onOpenChange,
onSubmit,
}: CompanyEditDialogBodyProps) {
const formId = "company-edit-form"
const [loading, setLoading] = useState(false)
const form = useForm<
z.input<typeof companyFormSchema>,
undefined,
z.output<typeof companyFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
const {
handleSubmit,
register,
reset,
formState: { errors },
} = form
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(buildInitialForm(initialValues))
return
}
setLoading(true)
try {
const data = await fetchCompany(itemId)
reset(buildForm(data))
} finally {
setLoading(false)
}
}
void loadDetail()
}, [initialValues, itemId, reset])
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values))
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑公司" : "新建公司"}
size="md"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="company-name"></FieldLabel>
<FieldContent>
<Input
id="company-name"
placeholder="请输入公司名称"
aria-invalid={!!errors.name}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="company-code"></FieldLabel>
<FieldContent>
<Input
id="company-code"
placeholder="可选"
aria-invalid={!!errors.code}
{...register("code")}
/>
<FieldError errors={[errors.code]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="company-remark"></FieldLabel>
<FieldContent>
<Textarea
id="company-remark"
placeholder="可选"
rows={4}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
)
}
@@ -0,0 +1,359 @@
"use client"
import {
BanIcon,
CheckCircle2Icon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { ListPagination } from "@/components/list-pagination"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { type PageResult } from "@/lib/api/admin"
import {
createCompany,
deleteCompany,
fetchCompanies,
updateCompany,
updateCompanyStatus,
type AdminCompany,
type CreateAdminCompanyPayload,
} from "@/lib/api/company"
import { getEnumOptions } from "@/lib/enums"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { EditDialog } from "./_components/edit"
const listStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: item.label,
})),
] as const
function getStatusLabel(
value: string,
options: ReadonlyArray<{ value: string; label: string }>
) {
return options.find((item) => item.value === value)?.label ?? "请选择状态"
}
export default function DashboardCompaniesPage() {
const [nameInput, setNameInput] = useState("")
const [codeInput, setCodeInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
const [name, setName] = useState("")
const [code, setCode] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<AdminCompany | null>(null)
const [result, setResult] = useState<PageResult<AdminCompany>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchCompanies({
name: name.trim() || undefined,
code: code.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载公司列表失败")
} finally {
setLoading(false)
}
}, [code, limit, name, page, statusFilter])
useEffect(() => {
void loadData()
}, [loadData])
function applyFilters() {
setName(nameInput)
setCode(codeInput)
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)
}
function openCreateDialog() {
setEditingItem(null)
setDialogOpen(true)
}
function openEditDialog(item: AdminCompany) {
setEditingItem(item)
setDialogOpen(true)
}
function handleDialogOpenChange(open: boolean) {
if (saving) return
if (!open) setEditingItem(null)
setDialogOpen(open)
}
async function handleSubmit(payload: CreateAdminCompanyPayload) {
if (saving) return
setSaving(true)
try {
if (editingItem) {
await updateCompany({ id: editingItem.id, ...payload })
toast.success(`已更新公司:${editingItem.name}`)
} else {
await createCompany(payload)
toast.success(`已创建公司:${payload.name}`)
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存公司失败")
} finally {
setSaving(false)
}
}
async function handleToggleStatus(item: AdminCompany) {
setActionLoadingId(item.id)
try {
const nextStatus = item.status === 0 ? 1 : 0
await updateCompanyStatus(item.id, nextStatus)
toast.success(`${nextStatus === 0 ? "启用" : "禁用"}${item.name}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
} finally {
setActionLoadingId(null)
}
}
async function handleDelete(item: AdminCompany) {
setActionLoadingId(item.id)
try {
await deleteCompany(item.id)
toast.success(`已删除公司:${item.name}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除公司失败")
} finally {
setActionLoadingId(null)
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-w-72">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按公司名称筛选"
className="pl-9"
/>
</div>
<Input
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按公司编码筛选"
className="w-full xl:w-48"
/>
<Select value={statusFilterInput} onValueChange={(v) => setStatusFilterInput(v ?? "all")}>
<SelectTrigger className="w-full xl:w-36">
<SelectValue>{getStatusLabel(statusFilterInput, listStatusOptions)}</SelectValue>
</SelectTrigger>
<SelectContent>
{listStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead></TableHead>
<TableHead className="w-40"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.length === 0 && !loading ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
result.results.map((item) => {
const actionLoading = actionLoadingId === item.id
return (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell className="text-muted-foreground">{item.code || "-"}</TableCell>
<TableCell>{item.customerCount}</TableCell>
<TableCell>
<Badge
variant={
item.status === Status.Ok
? "default"
: item.status === Status.Deleted
? "outline"
: "secondary"
}
>
{StatusLabels[item.status as Status] ?? "未知"}
</Badge>
</TableCell>
<TableCell className="max-w-[320px]">
<div className="line-clamp-2 text-muted-foreground">{item.remark || "-"}</div>
</TableCell>
<TableCell>
<ButtonGroup className="w-full justify-end">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" disabled={actionLoading} />
}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
disabled={item.status === Status.Deleted}
onClick={() => void handleToggleStatus(item)}
>
{actionLoading ? (
"处理中..."
) : item.status === Status.Ok ? (
<>
<BanIcon />
</>
) : (
<>
<CheckCircle2Icon />
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={item.status === Status.Deleted}
onClick={() => void handleDelete(item)}
>
<Trash2Icon />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
)
}
@@ -0,0 +1,560 @@
"use client";
import { CheckCheckIcon, EyeIcon, MessageCircleMoreIcon } from "lucide-react";
import Image from "next/image";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
} from "react";
import { ImMessageHTML } from "@/components/im-message-html";
import { useImageLightbox } from "@/components/image-lightbox";
import { ProjectDialog } from "@/components/project-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
type AdminConversation,
type AdminConversationDetail,
type AdminMessage,
} from "@/lib/api/admin";
import {
parseMessageAssetPayload,
renderIMMessageHTML,
} from "@/lib/im-message";
import { formatDateTime } from "@/lib/utils";
type ConversationDetailDialogProps = {
open: boolean;
loading: boolean;
saving: boolean;
item: AdminConversation | null;
detail: AdminConversationDetail | null;
messages: AdminMessage[];
/** 是否还有更早消息(cursor 分页) */
messagesHasMore?: boolean;
loadingMoreMessages?: boolean;
onLoadMoreMessages?: () => void | Promise<void>;
onOpenChange: (open: boolean) => void;
onOpenAssign: () => void;
onDispatch: () => Promise<void>;
onOpenTransfer: () => void;
onRead: () => Promise<void>;
onOpenClose: () => void;
};
function getStatusMeta(status: number) {
switch (status) {
case 1:
return { label: "AI接待中", variant: "secondary" as const };
case 2:
return { label: "待接入", variant: "outline" as const };
case 3:
return { label: "处理中", variant: "secondary" as const };
case 4:
return { label: "已关闭", variant: "outline" as const };
default:
return { label: "未知", variant: "outline" as const };
}
}
function getServiceModeLabel(mode: number) {
switch (mode) {
case 1:
return "AI 接待";
case 2:
return "人工接待";
case 3:
return "AI 优先";
default:
return "未定义";
}
}
function getSenderLabel(message: AdminMessage) {
switch (message.senderType) {
case "agent":
return message.senderName || "客服";
case "customer":
return message.senderName || "用户";
case "ai":
return "AI";
case "system":
return "系统";
default:
return message.senderType;
}
}
function getMessageContent(message: AdminMessage) {
return message.content || message.payload || "-";
}
function getImageMessageUrl(message: AdminMessage) {
return parseMessageAssetPayload(message.payload)?.url || "";
}
function getParticipantIdentity(
participant: NonNullable<AdminConversationDetail["participants"]>[number],
) {
return participant.participantId || participant.externalParticipantId || "-";
}
function getMessageLayout(message: AdminMessage) {
if (message.senderType === "customer") {
return {
rowClassName: "justify-start",
bubbleClassName: "bg-muted text-foreground border-border",
metaClassName: "text-left",
};
}
if (message.senderType === "system") {
return {
rowClassName: "justify-center",
bubbleClassName:
"bg-muted/60 text-muted-foreground border-dashed border-border",
metaClassName: "text-center",
};
}
if (message.senderType === "ai") {
return {
rowClassName: "justify-end",
bubbleClassName: "bg-primary/10 text-foreground border-primary/20",
metaClassName: "text-right",
};
}
return {
rowClassName: "justify-end",
bubbleClassName: "bg-primary text-primary-foreground border-primary",
metaClassName: "text-right",
};
}
export function ConversationDetailDialog({
open,
loading,
saving,
item,
detail,
messages,
messagesHasMore = false,
loadingMoreMessages = false,
onLoadMoreMessages,
onOpenChange,
onOpenAssign,
onDispatch,
onOpenTransfer,
onRead,
onOpenClose,
}: ConversationDetailDialogProps) {
const currentConversation = detail ?? item;
const isClosedConversation = currentConversation?.status === 4;
const isPendingConversation = currentConversation?.status === 2;
const statusMeta = currentConversation
? getStatusMeta(currentConversation.status)
: null;
const messageBottomRef = useRef<HTMLDivElement | null>(null);
const messagesScrollRootRef = useRef<HTMLDivElement | null>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement | null>(null);
const pendingScrollAnchorRef = useRef<{
scrollHeight: number;
scrollTop: number;
} | null>(null);
const prevLoadingMoreRef = useRef(false);
const { open: openImageLightbox, close: closeImageLightbox } =
useImageLightbox();
const getMessagesViewport = useCallback((): HTMLElement | null => {
return (
messagesScrollRootRef.current?.querySelector(
'[data-slot="scroll-area-viewport"]',
) ?? null
);
}, []);
useEffect(() => {
if (!open) {
closeImageLightbox();
return;
}
if (loading) {
return;
}
const bottom = messageBottomRef.current;
if (!bottom) {
return;
}
bottom.scrollIntoView({ block: "end", behavior: "smooth" });
}, [open, loading, closeImageLightbox]);
useLayoutEffect(() => {
const wasLoading = prevLoadingMoreRef.current;
prevLoadingMoreRef.current = loadingMoreMessages;
if (wasLoading && !loadingMoreMessages && pendingScrollAnchorRef.current) {
const vp = getMessagesViewport();
const anchor = pendingScrollAnchorRef.current;
pendingScrollAnchorRef.current = null;
if (vp && anchor) {
const delta = vp.scrollHeight - anchor.scrollHeight;
vp.scrollTop = anchor.scrollTop + delta;
}
}
}, [loadingMoreMessages, messages, getMessagesViewport]);
useEffect(() => {
if (!open || loading || !messagesHasMore || !onLoadMoreMessages) {
return;
}
const root = getMessagesViewport();
const sentinel = loadMoreSentinelRef.current;
if (!root || !sentinel) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const hit = entries.some((e) => e.isIntersecting);
if (!hit || loadingMoreMessages) {
return;
}
const vp = getMessagesViewport();
if (vp) {
pendingScrollAnchorRef.current = {
scrollHeight: vp.scrollHeight,
scrollTop: vp.scrollTop,
};
}
void onLoadMoreMessages();
},
{ root, rootMargin: "120px 0px 0px 0px", threshold: 0 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [
open,
loading,
messagesHasMore,
loadingMoreMessages,
messages.length,
onLoadMoreMessages,
getMessagesViewport,
]);
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={
<div className="flex items-center gap-3">
<span>{currentConversation?.subject || "会话详情"}</span>
<div>
{statusMeta ? (
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
) : null}
</div>
</div>
}
size="xl"
// allowFullscreen
defaultFullscreen
bodyScrollable={false}
bodyClassName="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
contentClassName="h-[calc(100vh-40px)] max-h-[calc(100vh-40px)]"
footer={
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<div className="text-sm text-muted-foreground">
{currentConversation
? `最后活跃:${formatDateTime(currentConversation.lastMessageAt)}`
: "暂无会话信息"}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={onOpenAssign}
disabled={saving || !currentConversation || currentConversation.status !== 2}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "分配会话"}
</Button>
<Button
variant="outline"
onClick={() => void onDispatch()}
disabled={
saving || !currentConversation || !isPendingConversation
}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "重试分配"}
</Button>
<Button
variant="outline"
onClick={() => void onRead()}
disabled={saving || !currentConversation}
>
<CheckCheckIcon />
{saving ? "处理中..." : "标记已读"}
</Button>
<Button
type="button"
variant="outline"
onClick={onOpenTransfer}
disabled={saving || !currentConversation || currentConversation.status !== 3}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "转接会话"}
</Button>
{!isClosedConversation ? (
<Button
variant="outline"
onClick={onOpenClose}
disabled={saving || !currentConversation}
>
<EyeIcon />
{saving ? "处理中..." : "关闭会话"}
</Button>
) : null}
</div>
</div>
}
>
{loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
...
</div>
) : currentConversation ? (
<div className="flex min-h-0 flex-1 flex-row overflow-hidden border-t">
<aside className="flex w-90 h-full shrink-0 flex-col overflow-hidden bg-muted/20 border-r border-b-0">
<div className="space-y-4 p-6">
<div className="grid grid-cols-2 gap-3 text-sm">
<InfoItem
label="接待模式"
value={getServiceModeLabel(currentConversation.serviceMode)}
/>
<InfoItem
label="当前客服"
value={currentConversation.currentAssigneeName || "-"}
/>
<InfoItem
label="渠道类型"
value={currentConversation.externalSource || "-"}
/>
<InfoItem
label="客服未读"
value={`${currentConversation.agentUnreadCount}`}
/>
<InfoItem
label="用户未读"
value={`${currentConversation.customerUnreadCount}`}
/>
<InfoItem
label="最后活跃"
value={formatDateTime(currentConversation.lastMessageAt)}
fullWidth
/>
<InfoItem
label="关闭时间"
value={formatDateTime(currentConversation.closedAt)}
fullWidth
/>
</div>
</div>
<Separator />
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 p-6">
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium"></p>
<span className="text-xs text-muted-foreground">
{detail?.participants?.length ?? 0}
</span>
</div>
{detail?.participants?.length ? (
<div className="space-y-3">
{detail.participants.map((participant) => (
<div
key={participant.id}
className="rounded-lg border bg-background p-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{participant.participantType || "-"}
</span>
</div>
<div className="mt-1 text-sm text-muted-foreground">
{getParticipantIdentity(participant)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(participant.joinedAt)}
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background p-4 text-sm text-muted-foreground">
</div>
)}
</section>
</div>
</ScrollArea>
</aside>
<section className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
{/* <div className="flex items-center justify-between border-b px-6 py-4">
<div>
<p className="text-sm font-medium">聊天记录</p>
</div>
<div className="text-xs text-muted-foreground">
消息数:{messages.length}
{messagesHasMore ? " · 向上滑可加载更早" : ""}
</div>
</div> */}
<div ref={messagesScrollRootRef} className="min-h-0 flex-1">
<ScrollArea className="h-full min-h-0 bg-muted/10">
<div className="space-y-4 px-6 py-5">
{messagesHasMore ? (
<div
ref={loadMoreSentinelRef}
className="flex min-h-8 flex-col items-center justify-center py-2 text-xs text-muted-foreground"
>
{loadingMoreMessages
? "正在加载更早的消息…"
: "继续上滑加载更早消息"}
</div>
) : null}
{messages.length ? (
messages.map((message) => {
const layout = getMessageLayout(message);
const isRecalled =
Boolean(message.recalledAt) || message.sendStatus === 6;
const isHtmlMessage =
!isRecalled &&
(message.messageType === "html" ||
message.messageType === "attachment");
const isImageMessage = !isRecalled && message.messageType === "image";
return (
<div
key={message.id}
className={`flex ${layout.rowClassName}`}
>
<div className="max-w-[85%] space-y-2">
<div
className={`text-xs text-muted-foreground ${layout.metaClassName}`}
>
<span>{getSenderLabel(message)}</span>
<span className="mx-2">·</span>
<span>{formatDateTime(message.sentAt)}</span>
{isRecalled ? (
<>
<span className="mx-2">·</span>
<span></span>
</>
) : null}
</div>
<div
className={`rounded-2xl border px-4 py-3 text-sm leading-6 ${layout.bubbleClassName}`}
>
{isRecalled ? (
<div className="text-muted-foreground"></div>
) : isHtmlMessage ? (
<ImMessageHTML
html={renderIMMessageHTML(message)}
className="[&_a]:underline [&_img]:max-w-full [&_img]:cursor-zoom-in"
onImageClick={openImageLightbox}
/>
) : isImageMessage ? (
<MessageImage
src={getImageMessageUrl(message)}
alt={getMessageContent(message)}
onPreview={openImageLightbox}
/>
) : (
<div className="whitespace-pre-wrap break-words">
{getMessageContent(message)}
</div>
)}
</div>
<div
className={`text-xs text-muted-foreground ${layout.metaClassName}`}
>
{message.agentRead ? "已读" : "未读"} / {" "}
{message.customerRead ? "已读" : "未读"}
</div>
</div>
</div>
);
})
) : (
<div className="flex h-full min-h-80 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
</div>
)}
<div ref={messageBottomRef} />
</div>
</ScrollArea>
</div>
</section>
</div>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
</div>
)}
</ProjectDialog>
);
}
type InfoItemProps = {
label: string;
value: string;
fullWidth?: boolean;
};
function InfoItem({ label, value, fullWidth = false }: InfoItemProps) {
return (
<div className={fullWidth ? "col-span-2" : undefined}>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 break-all font-medium">{value || "-"}</p>
</div>
);
}
type MessageImageProps = {
src: string;
alt: string;
onPreview: (src: string, alt?: string) => void;
};
function MessageImage({ src, alt, onPreview }: MessageImageProps) {
if (!src) {
return (
<div className="text-sm whitespace-pre-wrap break-words">
{alt || "[图片]"}
</div>
);
}
return (
<button
type="button"
className="block cursor-zoom-in"
onClick={() => onPreview(src, alt)}
>
<Image
src={src}
alt={alt || "消息图片"}
width={480}
height={360}
className="max-h-64 w-auto max-w-full rounded-md object-contain"
unoptimized
/>
</button>
);
}
@@ -0,0 +1,913 @@
"use client"
import {
CheckCheckIcon,
MessageCircleMoreIcon,
MoreHorizontalIcon,
RefreshCwIcon,
SearchIcon,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"
import { toast } from "sonner"
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"
import { ListPagination } from "@/components/list-pagination"
import {
OptionCombobox,
type ComboboxOption,
} from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
createAdminWebSocketUrl,
dispatchConversation,
fetchAgentProfilesAll,
fetchAgentTeamsAll,
fetchConversationDetail,
fetchConversationMessages,
fetchConversations,
fetchTagsAll,
markConversationRead,
type AdminAgentProfile,
type AdminAgentTeam,
type AdminConversation,
type AdminConversationDetail,
type AdminMessage,
type PageResult,
type TagTree,
} from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
import { ConversationDetailDialog } from "./_components/detail"
const RECONNECT_BASE_DELAY = 2000
const RECONNECT_MAX_DELAY = 30000
const statusOptions = [
{ value: "all", label: "全部状态" },
{ value: "1", label: "AI接待中" },
{ value: "2", label: "待接入" },
{ value: "3", label: "处理中" },
{ value: "4", label: "已关闭" },
] as const
function getStatusMeta(status: number) {
switch (status) {
case 1:
return { label: "AI接待中", variant: "secondary" as const }
case 2:
return { label: "待接入", variant: "outline" as const }
case 3:
return { label: "处理中", variant: "secondary" as const }
case 4:
return { label: "已关闭", variant: "outline" as const }
default:
return { label: "未知", variant: "outline" as const }
}
}
function getServiceModeLabel(mode: number) {
switch (mode) {
case 1:
return "AI 接待"
case 2:
return "人工接待"
case 3:
return "AI 优先"
default:
return "未定义"
}
}
function getStatusLabel(value: string) {
return statusOptions.find((item) => item.value === value)?.label ?? "全部状态"
}
function buildTagOptions(
nodes: TagTree[],
parentPath = ""
): ComboboxOption[] {
const result: ComboboxOption[] = []
nodes.forEach((item) => {
const currentPath = parentPath ? `${parentPath}/${item.name}` : item.name
result.push({
value: String(item.id),
label: currentPath,
})
if (item.children.length > 0) {
result.push(...buildTagOptions(item.children, currentPath))
}
})
return result
}
export default function DashboardConversationsPage() {
const [keywordInput, setKeywordInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
const [tagFilterInput, setTagFilterInput] = useState("0")
const [assigneeFilterInput, setAssigneeFilterInput] = useState("0")
const [agentTeamFilterInput, setAgentTeamFilterInput] = useState("0")
const [keyword, setKeyword] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [tagFilter, setTagFilter] = useState("0")
const [assigneeFilter, setAssigneeFilter] = useState("0")
const [agentTeamFilter, setAgentTeamFilter] = useState("0")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [tagOptions, setTagOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部标签" },
])
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部指派人" },
])
const [agentTeamOptions, setAgentTeamOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部客服组" },
])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailItem, setDetailItem] = useState<AdminConversation | null>(null)
const [detailData, setDetailData] = useState<AdminConversationDetail | null>(null)
const [detailMessages, setDetailMessages] = useState<AdminMessage[]>([])
const [detailMessagesNextCursor, setDetailMessagesNextCursor] = useState("")
const [detailMessagesHasMore, setDetailMessagesHasMore] = useState(false)
const [detailMessagesLoadingMore, setDetailMessagesLoadingMore] = useState(false)
const [assignOpen, setAssignOpen] = useState(false)
const [assignItem, setAssignItem] = useState<AdminConversation | null>(null)
const [closeOpen, setCloseOpen] = useState(false)
const [closeItem, setCloseItem] = useState<AdminConversation | null>(null)
const [transferOpen, setTransferOpen] = useState(false)
const [transferItem, setTransferItem] = useState<AdminConversation | null>(null)
const [result, setResult] = useState<PageResult<AdminConversation>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const websocketRef = useRef<WebSocket | null>(null)
const reconnectTimerRef = useRef<number | null>(null)
const pingTimerRef = useRef<number | null>(null)
const reconnectAttemptRef = useRef(0)
const detailItemRef = useRef<AdminConversation | null>(null)
const subscribedConversationIdRef = useRef<number | null>(null)
const loadConversations = useCallback(async () => {
setLoading(true)
try {
const data = await fetchConversations({
keyword: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
tagId: tagFilter === "0" ? undefined : tagFilter,
currentAssigneeId: assigneeFilter === "0" ? undefined : assigneeFilter,
agentTeamId: agentTeamFilter === "0" ? undefined : agentTeamFilter,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载会话列表失败")
} finally {
setLoading(false)
}
}, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter])
useEffect(() => {
let cancelled = false
async function loadFilterOptions() {
try {
const [tagData, assigneeData, teamData] = await Promise.all([
fetchTagsAll(),
fetchAgentProfilesAll(),
fetchAgentTeamsAll(),
])
if (!cancelled) {
setTagOptions([
{ value: "0", label: "全部标签" },
...buildTagOptions(tagData),
])
setAssigneeOptions([
{ value: "0", label: "全部指派人" },
...assigneeData.map((item: AdminAgentProfile) => ({
value: String(item.userId),
label: item.displayName || item.nickname || item.username || `#${item.userId}`,
})),
])
setAgentTeamOptions([
{ value: "0", label: "全部客服组" },
...teamData.map((item: AdminAgentTeam) => ({
value: String(item.id),
label: item.name,
})),
])
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载筛选项失败")
}
}
}
void loadFilterOptions()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
detailItemRef.current = detailItem
}, [detailItem])
useEffect(() => {
void loadConversations()
}, [loadConversations])
useEffect(() => {
let cancelled = false
const clearTimers = () => {
if (reconnectTimerRef.current) {
window.clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (pingTimerRef.current) {
window.clearInterval(pingTimerRef.current)
pingTimerRef.current = null
}
}
const scheduleReconnect = () => {
if (cancelled || reconnectTimerRef.current) {
return
}
const delay = Math.min(
RECONNECT_BASE_DELAY * 2 ** reconnectAttemptRef.current,
RECONNECT_MAX_DELAY
)
reconnectTimerRef.current = window.setTimeout(() => {
reconnectTimerRef.current = null
reconnectAttemptRef.current += 1
connect()
}, delay)
}
const connect = () => {
if (cancelled) {
return
}
let socket: WebSocket
try {
socket = new WebSocket(createAdminWebSocketUrl())
} catch (error) {
toast.error(error instanceof Error ? error.message : "连接实时服务失败")
scheduleReconnect()
return
}
websocketRef.current = socket
socket.onopen = () => {
reconnectAttemptRef.current = 0
if (pingTimerRef.current) {
window.clearInterval(pingTimerRef.current)
}
pingTimerRef.current = window.setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ping" }))
}
}, 20000)
const conversationId = detailItemRef.current?.id
if (conversationId) {
socket.send(
JSON.stringify({
type: "subscribe",
topics: [`conversation:${conversationId}`],
})
)
subscribedConversationIdRef.current = conversationId
} else {
subscribedConversationIdRef.current = null
}
}
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as {
eventId?: string
type?: string
data?: { conversationId?: number }
}
const eventType = payload.type ?? ""
const conversationId = payload.data?.conversationId ?? 0
const eventId = payload.eventId?.trim() ?? ""
if (
eventType === "" ||
eventType === "connected" ||
eventType === "pong" ||
eventType === "subscribed" ||
eventType === "unsubscribed"
) {
return
}
if (eventId && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ack", eventId }))
}
void loadConversations()
const currentDetail = detailItemRef.current
if (conversationId > 0 && currentDetail?.id === conversationId) {
void loadDetail(currentDetail)
}
} catch {
// ignore invalid ws payload
}
}
socket.onclose = () => {
if (pingTimerRef.current) {
window.clearInterval(pingTimerRef.current)
pingTimerRef.current = null
}
if (websocketRef.current === socket) {
websocketRef.current = null
}
subscribedConversationIdRef.current = null
scheduleReconnect()
}
}
connect()
return () => {
cancelled = true
clearTimers()
reconnectAttemptRef.current = 0
const socket = websocketRef.current
websocketRef.current = null
if (socket) {
socket.close()
}
subscribedConversationIdRef.current = null
}
}, [loadConversations])
useEffect(() => {
const socket = websocketRef.current
if (!socket || socket.readyState !== WebSocket.OPEN) {
return
}
const previousConversationId = subscribedConversationIdRef.current
const nextConversationId = detailOpen ? detailItem?.id ?? null : null
if (previousConversationId && previousConversationId !== nextConversationId) {
socket.send(
JSON.stringify({
type: "unsubscribe",
topics: [`conversation:${previousConversationId}`],
})
)
}
if (nextConversationId && nextConversationId !== previousConversationId) {
socket.send(
JSON.stringify({
type: "subscribe",
topics: [`conversation:${nextConversationId}`],
})
)
subscribedConversationIdRef.current = nextConversationId
return
}
if (!nextConversationId) {
subscribedConversationIdRef.current = null
}
}, [detailItem, detailOpen])
function handleStatusFilterChange(value: string | null) {
setStatusFilterInput(value ?? "all")
}
function applyFilters() {
setKeyword(keywordInput)
setStatusFilter(statusFilterInput)
setTagFilter(tagFilterInput)
setAssigneeFilter(assigneeFilterInput)
setAgentTeamFilter(agentTeamFilterInput)
setPage(1)
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
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)
}
async function loadDetail(item: AdminConversation) {
setDetailLoading(true)
setDetailMessagesNextCursor("")
setDetailMessagesHasMore(false)
try {
const [detail, messages] = await Promise.all([
fetchConversationDetail(item.id),
fetchConversationMessages({ conversationId: item.id, limit: 20 }),
])
setDetailData(detail)
setDetailMessages(messages.results)
setDetailMessagesNextCursor(messages.cursor ?? "")
setDetailMessagesHasMore(Boolean(messages.hasMore))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载会话详情失败")
} finally {
setDetailLoading(false)
}
}
const loadMoreDetailMessages = useCallback(async () => {
if (!detailItem || detailMessagesLoadingMore || !detailMessagesHasMore) {
return
}
const cursor = Number.parseInt(detailMessagesNextCursor, 10)
if (!detailMessagesNextCursor.trim() || !Number.isFinite(cursor) || cursor <= 0) {
return
}
setDetailMessagesLoadingMore(true)
try {
const page = await fetchConversationMessages({
conversationId: detailItem.id,
cursor,
limit: 20,
})
setDetailMessages((prev) => [...page.results, ...prev])
setDetailMessagesNextCursor(page.cursor ?? "")
setDetailMessagesHasMore(Boolean(page.hasMore))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载更多消息失败")
} finally {
setDetailMessagesLoadingMore(false)
}
}, [
detailItem,
detailMessagesHasMore,
detailMessagesLoadingMore,
detailMessagesNextCursor,
])
async function openDetail(item: AdminConversation) {
setDetailItem(item)
setDetailData(null)
setDetailMessages([])
setDetailMessagesNextCursor("")
setDetailMessagesHasMore(false)
setDetailMessagesLoadingMore(false)
setDetailOpen(true)
await loadDetail(item)
}
function handleDetailOpenChange(open: boolean) {
if (actionLoadingId) {
return
}
if (!open) {
setDetailOpen(false)
setDetailItem(null)
setDetailData(null)
setDetailMessages([])
setDetailMessagesNextCursor("")
setDetailMessagesHasMore(false)
setDetailMessagesLoadingMore(false)
return
}
setDetailOpen(true)
}
function openAssign(item: AdminConversation) {
setAssignItem(item)
setAssignOpen(true)
}
function handleAssignOpenChange(open: boolean) {
if (actionLoadingId) {
return
}
if (!open) {
setAssignOpen(false)
setAssignItem(null)
return
}
setAssignOpen(true)
}
function openTransfer(item: AdminConversation) {
setTransferItem(item)
setTransferOpen(true)
}
function openClose(item: AdminConversation) {
setCloseItem(item)
setCloseOpen(true)
}
function handleCloseOpenChange(open: boolean) {
if (actionLoadingId) {
return
}
if (!open) {
setCloseOpen(false)
setCloseItem(null)
return
}
setCloseOpen(true)
}
function handleTransferOpenChange(open: boolean) {
if (actionLoadingId) {
return
}
if (!open) {
setTransferOpen(false)
setTransferItem(null)
return
}
setTransferOpen(true)
}
async function refreshDetail() {
if (!detailOpen || !detailItem) {
return
}
await loadDetail(detailItem)
}
async function handleRefresh() {
setRefreshing(true)
try {
await loadConversations()
await refreshDetail()
} finally {
setRefreshing(false)
}
}
async function handleRead(item: AdminConversation) {
setActionLoadingId(item.id)
try {
await markConversationRead(item.id)
toast.success(`已标记已读:${item.subject || `#${item.id}`}`)
await loadConversations()
if (detailItem?.id === item.id) {
await refreshDetail()
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "标记已读失败")
} finally {
setActionLoadingId(null)
}
}
async function handleDispatch(item: AdminConversation) {
setActionLoadingId(item.id)
try {
await dispatchConversation(item.id)
toast.success(`已触发自动分配:${item.subject || `#${item.id}`}`)
await loadConversations()
if (detailItem?.id === item.id) {
await refreshDetail()
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "自动分配失败")
} finally {
setActionLoadingId(null)
}
}
async function handleConversationChanged(conversationId: number) {
await loadConversations()
if (detailItem?.id === conversationId) {
await refreshDetail()
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-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="按主题或摘要筛选"
className="pl-9"
/>
</div>
<Select value={statusFilterInput} onValueChange={handleStatusFilterChange}>
<SelectTrigger className="w-full xl:w-36">
<SelectValue>{getStatusLabel(statusFilterInput)}</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full xl:w-64">
<OptionCombobox
value={tagFilterInput}
options={tagOptions}
placeholder="选择标签"
searchPlaceholder="搜索标签路径"
emptyText="没有匹配标签"
onChange={setTagFilterInput}
/>
</div>
<div className="w-full xl:w-56">
<OptionCombobox
value={assigneeFilterInput}
options={assigneeOptions}
placeholder="选择指派人"
searchPlaceholder="搜索指派人"
emptyText="没有匹配指派人"
onChange={setAssigneeFilterInput}
/>
</div>
<div className="w-full xl:w-56">
<OptionCombobox
value={agentTeamFilterInput}
options={agentTeamOptions}
placeholder="选择客服组"
searchPlaceholder="搜索客服组"
emptyText="没有匹配客服组"
onChange={setAgentTeamFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button
variant="outline"
onClick={() => void handleRefresh()}
disabled={loading || refreshing}
>
<RefreshCwIcon className={loading || refreshing ? "animate-spin" : ""} />
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => {
const statusMeta = getStatusMeta(item.status)
return (
<TableRow key={item.id}>
<TableCell className="max-w-60">
<div className="min-w-0">
<div className="font-medium">{item.subject || `会话 #${item.id}`}</div>
<div className="mt-1 text-sm text-muted-foreground">
{item.externalSource || "-"}
</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.lastMessageSummary || "暂无最新消息摘要"}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
</TableCell>
<TableCell>{getServiceModeLabel(item.serviceMode)}</TableCell>
<TableCell>{item.currentAssigneeName || "-"}</TableCell>
<TableCell>
{item.agentUnreadCount} / {item.customerUnreadCount}
</TableCell>
<TableCell>{formatDateTime(item.lastMessageAt)}</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => void openDetail(item)}
disabled={actionLoadingId === item.id}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.subject || item.id}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 min-w-44">
<DropdownMenuItem
onClick={() => openAssign(item)}
disabled={actionLoadingId === item.id || item.status !== 2}
>
<MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "分配会话"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDispatch(item)}
disabled={actionLoadingId === item.id || item.status !== 2}
>
<RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "重试分配"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleRead(item)}
disabled={actionLoadingId === item.id}
>
<CheckCheckIcon />
{actionLoadingId === item.id ? "处理中..." : "标记已读"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openTransfer(item)}
disabled={actionLoadingId === item.id || item.status !== 3}
>
<MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "转接会话"}
</DropdownMenuItem>
{item.status !== 4 ? (
<DropdownMenuItem
onClick={() => openClose(item)}
disabled={actionLoadingId === item.id}
>
<RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "关闭会话"}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
)
})}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading || refreshing}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
<ConversationDetailDialog
open={detailOpen}
loading={detailLoading}
saving={actionLoadingId === detailItem?.id}
item={detailItem}
detail={detailData}
messages={detailMessages}
messagesHasMore={detailMessagesHasMore}
loadingMoreMessages={detailMessagesLoadingMore}
onLoadMoreMessages={loadMoreDetailMessages}
onOpenChange={handleDetailOpenChange}
onOpenAssign={() => {
if (!detailItem) {
return
}
openAssign(detailItem)
}}
onDispatch={async () => {
if (!detailItem) {
return
}
await handleDispatch(detailItem)
}}
onOpenTransfer={() => {
if (!detailItem) {
return
}
openTransfer(detailItem)
}}
onRead={async () => {
if (!detailItem) {
return
}
await handleRead(detailItem)
}}
onOpenClose={() => {
if (!detailItem) {
return
}
openClose(detailItem)
}}
/>
<ConversationCloseDialog
open={closeOpen}
conversationId={closeItem?.id ?? null}
onOpenChange={handleCloseOpenChange}
onSuccess={async () => {
const conversationId = closeItem?.id
setCloseOpen(false)
setCloseItem(null)
if (conversationId) {
await handleConversationChanged(conversationId)
}
}}
/>
<ConversationTransferDialog
open={assignOpen}
mode="assign"
conversationId={assignItem?.id ?? null}
onOpenChange={handleAssignOpenChange}
onSuccess={async () => {
const conversationId = assignItem?.id
setAssignOpen(false)
setAssignItem(null)
if (conversationId) {
await handleConversationChanged(conversationId)
}
}}
/>
<ConversationTransferDialog
open={transferOpen}
mode="transfer"
conversationId={transferItem?.id ?? null}
onOpenChange={handleTransferOpenChange}
onSuccess={async () => {
const conversationId = transferItem?.id
setTransferOpen(false)
setTransferItem(null)
if (conversationId) {
await handleConversationChanged(conversationId)
}
}}
/>
</>
)
}
@@ -0,0 +1,679 @@
"use client";
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { ImMessageEditor } from "@/components/im-message-editor";
import { ImMessageHTML } from "@/components/im-message-html";
import { useImageLightbox } from "@/components/image-lightbox";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useIsLgUp } from "@/hooks/use-lg-media";
import {
assignAgentConversation,
type AgentMessage,
} from "@/lib/api/agent";
import { readSession } from "@/lib/auth";
import { renderIMMessageHTML } from "@/lib/im-message";
import {
agentConversationSelectors,
useAgentConversationsStore,
type AgentConversationFilterKey,
} from "@/lib/stores/agent-conversations";
import { formatDateTime } from "@/lib/utils";
const EMPTY_AGENT_MESSAGES: AgentMessage[] = [];
export function ChatPanel() {
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
const messages =
useAgentConversationsStore((state) => state.messages) ??
EMPTY_AGENT_MESSAGES;
const loading = useAgentConversationsStore((state) => state.messagesLoading);
const sending = useAgentConversationsStore((state) => state.sending);
const uploadingAsset = useAgentConversationsStore(
(state) => state.uploadingAsset,
);
const sendMessage = useAgentConversationsStore((state) => state.sendMessage);
const uploadImage = useAgentConversationsStore((state) => state.uploadImage);
const sendAttachment = useAgentConversationsStore((state) => state.sendAttachment);
const markSelectedConversationRead = useAgentConversationsStore(
(state) => state.markSelectedConversationRead,
);
const recallMessage = useAgentConversationsStore((state) => state.recallMessage);
const recallingMessageId = useAgentConversationsStore(
(state) => state.recallingMessageId,
);
const loadConversations = useAgentConversationsStore((state) => state.loadConversations);
const loadMessages = useAgentConversationsStore((state) => state.loadMessages);
const loadOlderMessages = useAgentConversationsStore(
(state) => state.loadOlderMessages,
);
const messagesHasMore = useAgentConversationsStore(
(state) => state.messagesHasMore,
);
const messagesLoadingMore = useAgentConversationsStore(
(state) => state.messagesLoadingMore,
);
const conversationFilter = useAgentConversationsStore((state) => state.conversationFilter);
const setConversationFilter = useAgentConversationsStore(
(state) => state.setConversationFilter,
);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const messagesContentRef = useRef<HTMLDivElement>(null);
const scrollBottomRafRef = useRef<number | null>(null);
const shouldStickToBottomRef = useRef(true);
const prependScrollAnchorRef = useRef<{ height: number; top: number } | null>(
null,
);
const [claiming, setClaiming] = useState(false);
const [claimDialogOpen, setClaimDialogOpen] = useState(false);
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
const isLgUp = useIsLgUp();
const isClosedConversation = conversation?.status === 4;
const isPendingConversation = conversation?.status === 2;
const showMessageEditor = !isClosedConversation && !isPendingConversation;
const currentUserId = readSession()?.user?.id ?? 0;
const switchToMyActiveIfNeeded = () => {
if (conversationFilter !== "pending") {
return;
}
setConversationFilter("active" satisfies AgentConversationFilterKey);
};
const getViewport = useCallback(
() => messagesContainerRef.current,
[],
);
const isNearBottom = useCallback(
(element: HTMLElement, threshold = 80) =>
element.scrollHeight - element.scrollTop - element.clientHeight <=
threshold,
[],
);
const scrollToBottom = useCallback(() => {
const viewport = getViewport();
if (!viewport) {
return;
}
viewport.scrollTop = viewport.scrollHeight;
}, [getViewport]);
/**
* 与 widget 消息列表一致:在单条调度链内多帧滚底直到 scrollHeight 稳定,
* 避免多段滚底叠加导致滚动条抖动。
*/
const scheduleScrollToBottom = useCallback(
(attempts = 4) => {
if (scrollBottomRafRef.current !== null) {
cancelAnimationFrame(scrollBottomRafRef.current);
}
const run = (remaining: number, previousHeight = -1) => {
scrollBottomRafRef.current = requestAnimationFrame(() => {
const viewport = getViewport();
if (!viewport) {
scrollBottomRafRef.current = null;
return;
}
const currentHeight = viewport.scrollHeight;
scrollToBottom();
if (remaining > 1 && currentHeight !== previousHeight) {
run(remaining - 1, currentHeight);
return;
}
scrollBottomRafRef.current = null;
});
};
run(attempts);
},
[getViewport, scrollToBottom],
);
const handleImageSettled = useCallback(() => {
if (!shouldStickToBottomRef.current) {
return;
}
scheduleScrollToBottom();
}, [scheduleScrollToBottom]);
const maybeMarkConversationRead = useCallback(() => {
const viewport = getViewport();
if (!viewport || !conversation || loading) {
return;
}
if (
typeof document !== "undefined" &&
document.visibilityState !== "visible"
) {
return;
}
if (!isNearBottom(viewport)) {
return;
}
void markSelectedConversationRead().catch((error) => {
toast.error(error instanceof Error ? error.message : "设置已读失败");
});
}, [
conversation,
getViewport,
isNearBottom,
loading,
markSelectedConversationRead,
]);
useEffect(() => {
const viewport = getViewport();
if (!viewport) {
return;
}
const handleScroll = () => {
shouldStickToBottomRef.current = isNearBottom(viewport);
if (shouldStickToBottomRef.current) {
maybeMarkConversationRead();
}
};
handleScroll();
viewport.addEventListener("scroll", handleScroll);
return () => {
viewport.removeEventListener("scroll", handleScroll);
};
}, [conversation?.id, getViewport, isNearBottom, maybeMarkConversationRead]);
useLayoutEffect(() => {
shouldStickToBottomRef.current = true;
scheduleScrollToBottom();
return () => {
if (scrollBottomRafRef.current !== null) {
cancelAnimationFrame(scrollBottomRafRef.current);
scrollBottomRafRef.current = null;
}
};
}, [conversation?.id, scheduleScrollToBottom]);
useLayoutEffect(() => {
const viewport = getViewport();
if (!viewport) {
return;
}
const anchor = prependScrollAnchorRef.current;
if (anchor) {
prependScrollAnchorRef.current = null;
const nextHeight = viewport.scrollHeight;
viewport.scrollTop = nextHeight - anchor.height + anchor.top;
return;
}
if (shouldStickToBottomRef.current) {
scheduleScrollToBottom();
}
}, [messages, getViewport, scheduleScrollToBottom]);
useEffect(() => {
const content = messagesContentRef.current;
if (!content) {
return;
}
const observer = new ResizeObserver(() => {
if (!shouldStickToBottomRef.current) {
return;
}
scheduleScrollToBottom();
});
observer.observe(content);
return () => {
observer.disconnect();
};
}, [conversation?.id, scheduleScrollToBottom]);
useEffect(() => {
maybeMarkConversationRead();
}, [maybeMarkConversationRead, messages.length]);
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
maybeMarkConversationRead();
}
};
const handleFocus = () => {
maybeMarkConversationRead();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleFocus);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleFocus);
};
}, [maybeMarkConversationRead]);
const handleLoadOlder = async () => {
const viewport = getViewport();
if (!viewport || messagesLoadingMore || !messagesHasMore) {
return;
}
prependScrollAnchorRef.current = {
height: viewport.scrollHeight,
top: viewport.scrollTop,
};
try {
await loadOlderMessages();
} catch (error) {
prependScrollAnchorRef.current = null;
toast.error(error instanceof Error ? error.message : "加载历史消息失败");
}
};
const handleSend = async (html: string) => {
if (!conversation || sending || isClosedConversation) return;
try {
shouldStickToBottomRef.current = true;
await sendMessage(html);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送消息失败");
}
};
const handleClaim = async () => {
if (!conversation || claiming) return;
const session = readSession();
if (!session?.user?.id) {
toast.error("未登录或登录已过期");
return;
}
setClaiming(true);
try {
await assignAgentConversation(
conversation.id,
session.user.id,
"认领会话",
);
switchToMyActiveIfNeeded();
setClaimDialogOpen(false);
toast.success("认领成功");
await reloadConversationData(conversation.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "认领会话失败");
} finally {
setClaiming(false);
}
};
const reloadConversationData = async (conversationId: number) => {
await loadConversations();
await loadMessages(conversationId, { forceLoading: true, reset: true });
};
if (!conversation) {
return (
<div className="mt-10 flex flex-1 items-center justify-center px-4">
<div className="text-center text-muted-foreground">
<p className="text-lg"></p>
<p className="mt-1 text-sm lg:hidden"></p>
<p className="mt-1 hidden text-sm lg:block"></p>
</div>
</div>
);
}
const messagesScroll = (
<div
ref={messagesContainerRef}
className="h-full min-h-0 flex-1 overflow-y-auto p-4 cs-agent-scrollbar"
>
<div ref={messagesContentRef} className="flex flex-col">
{!loading && messages.length > 0 && messagesHasMore ? (
<div className="mb-4 flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={messagesLoadingMore}
onClick={() => void handleLoadOlder()}
>
{messagesLoadingMore ? "加载中…" : "加载更早的消息"}
</Button>
</div>
) : null}
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
</div>
) : messages.length > 0 ? (
messages.map((message) => (
<MessageItem
key={message.id}
message={message}
onImageSettled={handleImageSettled}
canRecall={message.senderType === "agent" && message.senderId === currentUserId}
recalling={recallingMessageId === message.id}
onRecall={async (messageId) => {
await recallMessage(messageId);
}}
/>
))
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</div>
);
const bottomPanel = (
<div className="h-full overflow-auto border-t border-border bg-background">
{isClosedConversation ? (
<div className="h-full flex justify-center items-center">
</div>
) : conversation?.status === 1 ? (
<div className="h-full flex justify-center items-center">
AI
</div>
) : isPendingConversation ? (
<div className="h-full flex justify-center items-center">
<div className="flex items-center gap-2 h-full">
<Button
onClick={() => setClaimDialogOpen(true)}
disabled={claiming}
size="sm"
>
{claiming ? "认领中..." : "认领"}
</Button>
</div>
</div>
) : (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<ImMessageEditor
disabled={!conversation || sending}
uploadingAsset={uploadingAsset}
onSend={handleSend}
onUploadImage={async (file) => {
shouldStickToBottomRef.current = true;
const uploaded = await uploadImage(file);
return uploaded
? { url: uploaded.url, filename: uploaded.filename }
: null;
}}
onSendAttachment={async (file) => {
shouldStickToBottomRef.current = true;
try {
await sendAttachment(file);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送附件失败");
}
}}
/>
</div>
</div>
)}
</div>
);
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{isLgUp ? (
<ResizablePanelGroup
orientation="vertical"
className="flex min-h-0 flex-1 flex-col"
>
<ResizablePanel
defaultSize={showMessageEditor ? "72%" : "82%"}
minSize="35%"
className="min-h-0"
>
{messagesScroll}
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={showMessageEditor ? "28%" : "18%"}
minSize={showMessageEditor ? "18%" : "12%"}
maxSize={showMessageEditor ? "55%" : "30%"}
className="min-h-0"
>
{bottomPanel}
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1">{messagesScroll}</div>
<div className="shrink-0 pb-[env(safe-area-inset-bottom)] lg:pb-0">
{bottomPanel}
</div>
</div>
)}
<Dialog
open={claimDialogOpen}
onOpenChange={(open) => {
if (claiming) {
return;
}
setClaimDialogOpen(open);
}}
>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{conversation
? `确认认领“${conversation.subject}”吗?认领后会话会进入我的列表。`
: "确认认领当前会话吗?"}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={claiming}
onClick={() => setClaimDialogOpen(false)}
>
</Button>
<Button
type="button"
disabled={claiming}
onClick={() => void handleClaim()}
>
{claiming ? "认领中..." : "确认认领"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConversationTransferDialog
open={transferDialogOpen}
mode="transfer"
conversationId={conversation.id}
onOpenChange={setTransferDialogOpen}
onSuccess={async () => {
await reloadConversationData(conversation.id);
}}
/>
</div>
);
}
type MessageItemProps = {
message: AgentMessage;
onImageSettled: () => void;
canRecall: boolean;
recalling: boolean;
onRecall: (messageId: number) => Promise<void>;
};
const MessageItem = memo(
function MessageItem({
message,
onImageSettled,
canRecall,
recalling,
onRecall,
}: MessageItemProps) {
const { open: openImageLightbox } = useImageLightbox();
const isCustomer = message.senderType === "customer";
const isAi = message.senderType === "ai";
const isAgentSide = message.senderType === "agent" || isAi;
const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6;
const senderName = isCustomer
? message.senderName || "客户"
: isAi
? "AI"
: message.senderName || "客服";
const agentAvatarSrc =
isAgentSide && !isAi && message.senderAvatar?.trim()
? message.senderAvatar.trim()
: undefined;
const avatarFallback = isAi ? "AI" : senderName.charAt(0);
const htmlContent = isRecalled ? "<p>该消息已撤回</p>" : buildMessageHTML(message);
const bubbleClassName = isAi
? "border border-primary/15 bg-primary/5 text-foreground shadow-sm"
: isAgentSide
? "bg-emerald-600 text-white shadow-sm"
: "border border-border/70 bg-muted/60 text-foreground shadow-sm";
const htmlClassName = isAi
? "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md"
: isAgentSide
? "[&_p]:text-white [&_a]:text-white [&_a]:underline [&_img]:rounded-md"
: "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md";
const avatarClassName = isAi
? "border border-primary/20 bg-primary/10 text-xs text-foreground"
: isAgentSide
? "bg-emerald-600 text-xs text-white"
: "border border-border/70 bg-muted/60 text-xs text-foreground";
const recalledBubbleClassName = isAgentSide
? "border border-dashed border-emerald-200 bg-emerald-50 text-emerald-800"
: "border border-dashed border-border/70 bg-muted/40 text-muted-foreground";
const recalledHtmlClassName = isAgentSide
? "[&_p]:text-emerald-800"
: "[&_p]:text-muted-foreground";
const showRecallAction = canRecall && !isRecalled;
return (
<div
className={`mb-4 flex items-start gap-2 ${
isAgentSide ? "justify-end" : "justify-start"
}`}
>
{isAgentSide ? (
<>
<div className="flex max-w-[70%] flex-col items-end">
<div className="mb-1 text-xs text-muted-foreground">
{senderName}
</div>
<div
className={`w-fit rounded-2xl px-3 py-2 text-left ${
isRecalled ? recalledBubbleClassName : bubbleClassName
}`}
>
<ImMessageHTML
html={htmlContent}
className={isRecalled ? recalledHtmlClassName : htmlClassName}
onImageSettled={onImageSettled}
onImageClick={isRecalled ? undefined : openImageLightbox}
/>
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span></span> : null}
{message.sendStatus === 2 && !isRecalled && (
<span>{message.customerRead ? "客户已读" : "客户未读"}</span>
)}
{showRecallAction ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-1 py-0 text-xs text-muted-foreground"
disabled={recalling}
onClick={() => {
void onRecall(message.id).catch((error) => {
toast.error(error instanceof Error ? error.message : "撤回消息失败");
});
}}
>
{recalling ? "撤回中..." : "撤回"}
</Button>
) : null}
</div>
</div>
<Avatar className="size-8 shrink-0">
<AvatarImage src={agentAvatarSrc ?? ""} />
<AvatarFallback className={avatarClassName}>
{avatarFallback}
</AvatarFallback>
</Avatar>
</>
) : (
<>
<Avatar className="size-8 shrink-0">
<AvatarImage src="" />
<AvatarFallback className={avatarClassName}>
</AvatarFallback>
</Avatar>
<div className="max-w-[70%]">
<div className="mb-1 text-xs text-muted-foreground">
{senderName}
</div>
<div
className={`w-fit rounded-2xl px-3 py-2 ${
isRecalled ? recalledBubbleClassName : bubbleClassName
}`}
>
<ImMessageHTML
html={htmlContent}
className={isRecalled ? recalledHtmlClassName : htmlClassName}
onImageSettled={onImageSettled}
onImageClick={isRecalled ? undefined : openImageLightbox}
/>
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span></span> : null}
</div>
</div>
</>
)}
</div>
);
},
(prevProps, nextProps) =>
prevProps.message === nextProps.message &&
prevProps.onImageSettled === nextProps.onImageSettled &&
prevProps.canRecall === nextProps.canRecall &&
prevProps.recalling === nextProps.recalling &&
prevProps.onRecall === nextProps.onRecall,
);
function buildMessageHTML(message: {
messageType: string;
content: string;
payload?: string;
}) {
return renderIMMessageHTML(message);
}
@@ -0,0 +1,757 @@
"use client";
import Link from "next/link";
import {
Building2Icon,
Link2Icon,
MailIcon,
PencilIcon,
PhoneIcon,
UserRoundIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { type CustomerFormSavePayload } from "@/components/customer-form";
import { CustomerFormDialog } from "@/components/customer-form-dialog";
import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Field,
FieldContent,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { AgentConversation } from "@/lib/api/agent";
import { type TagTree, fetchTagsAll } from "@/lib/api/admin";
import { updateCompany, type AdminCompany } from "@/lib/api/company";
import { fetchTickets, type TicketItem } from "@/lib/api/ticket";
import {
fetchCustomer,
saveCustomerProfile,
type AdminCustomer,
} from "@/lib/api/customer";
import {
fetchCustomerContacts,
type AdminCustomerContact,
} from "@/lib/api/customer-contact";
import {
ContactType,
ContactTypeLabels,
Gender,
GenderLabels,
} from "@/lib/generated/enums";
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
import { cn, formatDateTime } from "@/lib/utils";
import {
ConversationTagBadges,
ConversationTagPicker,
} from "./conversation-tag-picker";
import { TicketPriorityBadge } from "../../tickets/_components/ticket-priority-badge";
import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge";
function contactTypeLabel(contactType: ContactType | string) {
return ContactTypeLabels[contactType as ContactType] ?? contactType;
}
function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) {
const cls = "size-3.5 shrink-0 text-muted-foreground";
switch (contactType) {
case ContactType.Mobile:
return <PhoneIcon className={cls} aria-hidden />;
case ContactType.Email:
return <MailIcon className={cls} aria-hidden />;
default:
return <Link2Icon className={cls} aria-hidden />;
}
}
function DetailRow({
label,
value,
valueClassName,
}: {
label: string;
value: string;
valueClassName?: string;
}) {
const empty = !value.trim();
return (
<div className="flex gap-2.5 text-sm leading-snug">
<span className="w-17 shrink-0 pt-px text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 flex-1 break-all text-foreground",
empty && "text-muted-foreground",
valueClassName,
)}
>
{empty ? "—" : value}
</span>
</div>
);
}
function SectionHeading({
children,
action,
}: {
children: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-medium text-muted-foreground">{children}</h3>
{action}
</div>
);
}
function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
return (
<div className="space-y-6 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground"> CRM </p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
</p>
<Button
type="button"
className="mt-4 gap-2"
onClick={() => setLinkDialogOpen(true)}
>
<Link2Icon className="size-4" />
</Button>
</div>
<CustomerLinkOrCreateDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
conversationId={conversation.id}
onSuccess={() => void loadConversations()}
/>
</div>
);
}
function MissingCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
return (
<div className="space-y-6 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground"></p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
</p>
<Button
type="button"
className="mt-4 gap-2"
onClick={() => setLinkDialogOpen(true)}
>
<Link2Icon className="size-4" />
</Button>
</div>
<div className="space-y-2">
<SectionHeading>访</SectionHeading>
<div className="space-y-2">
<DetailRow label="外部来源" value={conversation.externalSource} />
<DetailRow label="外部标识" value={conversation.externalId} />
</div>
</div>
<CustomerLinkOrCreateDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
conversationId={conversation.id}
onSuccess={() => void loadConversations()}
/>
</div>
);
}
type ConversationInfoPanelProps = {
conversation: AgentConversation | null;
className?: string;
variant?: "default" | "embedded";
};
export function ConversationInfoPanel({
conversation,
className,
variant = "default",
}: ConversationInfoPanelProps) {
const embedded = variant === "embedded";
return (
<div
className={cn(
"flex h-full min-h-0 flex-col overflow-hidden",
embedded
? "bg-background text-foreground"
: "border-border bg-card text-card-foreground",
className,
)}
>
<div className="flex h-12.5 shrink-0 items-center border-b border-border px-3">
<h2 className="text-sm font-medium text-foreground"></h2>
</div>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-3 pb-4",
embedded && "pb-[max(1rem,env(safe-area-inset-bottom))] pt-1",
)}
>
{!conversation ? (
<p className="pt-4 text-sm text-muted-foreground">
{embedded
? "请选择会话以查看会话信息"
: "请选择左侧会话以查看会话信息"}
</p>
) : (
<div className="space-y-4 py-3">
<CustomerBody conversation={conversation} />
</div>
)}
</div>
</div>
);
}
function ConversationTagSection({
conversation,
}: {
conversation: AgentConversation;
}) {
const setConversationTags = useAgentConversationsStore(
(state) => state.setConversationTags,
);
const [availableTags, setAvailableTags] = useState<TagTree[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
async function loadTags() {
setLoading(true);
try {
const data = await fetchTagsAll();
if (!cancelled) {
setAvailableTags(Array.isArray(data) ? data : []);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载标签失败");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadTags();
return () => {
cancelled = true;
};
}, []);
return (
<section className="space-y-2 border-t pt-2">
<SectionHeading
action={
<ConversationTagPicker
conversation={conversation}
availableTags={availableTags}
loading={loading}
onTagsChange={(tags) => {
setConversationTags(conversation.id, tags);
}}
/>
}
>
</SectionHeading>
<ConversationTagBadges
tags={conversation.tags}
availableTags={availableTags}
/>
{!conversation.tags || conversation.tags.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : null}
</section>
);
}
function CustomerBody({ conversation }: { conversation: AgentConversation }) {
const customerId = conversation.customerId ?? 0;
if (customerId <= 0) {
return (
<div className="space-y-4">
<UnlinkedCustomerEmpty conversation={conversation} />
<ConversationTagSection conversation={conversation} />
</div>
);
}
return <CustomerLinkedBody conversation={conversation} customerId={customerId} />;
}
type CustomerLinkedBodyProps = {
conversation: AgentConversation;
customerId: number;
};
function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProps) {
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<AdminCustomer | null>(null);
const [contacts, setContacts] = useState<AdminCustomerContact[]>([]);
const [customerEditOpen, setCustomerEditOpen] = useState(false);
const [customerEditSaving, setCustomerEditSaving] = useState(false);
const [companyEditOpen, setCompanyEditOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const c = await fetchCustomer(customerId);
setCustomer(c);
if (!c) {
setContacts([]);
return;
}
const list = await fetchCustomerContacts(customerId);
setContacts(Array.isArray(list) ? list : []);
} catch (e) {
const msg = e instanceof Error ? e.message : "加载客户信息失败";
toast.error(msg);
setCustomer(null);
setContacts([]);
} finally {
setLoading(false);
}
}, [customerId]);
useEffect(() => {
void load();
}, [load]);
const isProfileEmpty =
customer &&
!customer.name.trim() &&
!customer.primaryMobile.trim() &&
!customer.primaryEmail.trim() &&
customer.companyId === 0 &&
!customer.remark.trim();
if (loading && !customer) {
return (
<p className="pt-4 text-sm text-muted-foreground"></p>
);
}
if (!customer) {
return (
<div className="space-y-4">
<MissingCustomerEmpty conversation={conversation} />
<ConversationTagSection conversation={conversation} />
</div>
);
}
const displayName = customer.name.trim() || "未填写姓名";
const company = customer.company ?? null;
const genderLabel =
customer.gender === Gender.Male || customer.gender === Gender.Female
? GenderLabels[customer.gender as Gender] ?? String(customer.gender)
: null;
return (
<div className="space-y-4">
{isProfileEmpty ? (
<div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-relaxed text-amber-950 dark:text-amber-100">
</div>
) : null}
<section className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2 text-sm">
<UserRoundIcon
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="line-clamp-2 leading-snug text-foreground">
<span className="font-medium">{displayName}</span>
{genderLabel ? (
<span className="font-normal text-muted-foreground">
{" "}
· {genderLabel}
</span>
) : null}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
onClick={() => setCustomerEditOpen(true)}
>
<PencilIcon className="size-3.5" />
</Button>
</div>
<div className="space-y-2">
<DetailRow
label="最近活跃"
value={
customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""
}
/>
<DetailRow
label="备注"
value={customer.remark.trim() ? customer.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
<DetailRow
label="创建时间"
value={formatDateTime(customer.createdAt)}
valueClassName="whitespace-pre-wrap"
/>
<DetailRow
label="更新时间"
value={formatDateTime(customer.updatedAt)}
valueClassName="whitespace-pre-wrap"
/>
</div>
</section>
<section className="space-y-2">
{contacts.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<ul className="space-y-3">
{contacts.map((row) => {
const tags: string[] = [];
if (row.isPrimary) {
tags.push("主");
}
if (row.isVerified) {
tags.push("已验证");
}
return (
<li key={row.id} className="text-sm">
<div className="flex items-center gap-2">
<ContactTypeIcon contactType={row.contactType} />
<div className="min-w-0 flex-1">
<p className="break-all font-medium leading-snug text-foreground">
{row.contactValue}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{contactTypeLabel(row.contactType)}
</span>
{tags.length > 0 ? (
<span className="ml-2 text-xs text-muted-foreground">
{tags.join(" · ")}
</span>
) : null}
</p>
{row.remark ? (
<p className="mt-1 line-clamp-3 break-all text-xs leading-relaxed text-muted-foreground">
{row.remark}
</p>
) : null}
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
{customer.companyId > 0 ? (
<section className="border-t pt-2">
{company ? (
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2 text-sm">
<Building2Icon
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="line-clamp-2 font-medium leading-snug text-foreground">
{company.name}
</p>
{company.code ? (
<p className="font-mono text-xs text-muted-foreground">
{company.code}
</p>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
onClick={() => setCompanyEditOpen(true)}
>
<PencilIcon className="size-3.5" />
</Button>
</div>
<div className="space-y-2 pt-1">
<DetailRow
label="创建"
value={formatDateTime(company.createdAt)}
/>
<DetailRow
label="更新"
value={formatDateTime(company.updatedAt)}
/>
</div>
<DetailRow
label="备注"
value={company.remark.trim() ? company.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
</p>
)}
</section>
) : null}
<RelatedTicketsSection conversation={conversation} />
<ConversationTagSection conversation={conversation} />
<CustomerFormDialog
open={customerEditOpen}
onOpenChange={setCustomerEditOpen}
saving={customerEditSaving}
itemId={customer.id}
onSave={async (payload: CustomerFormSavePayload) => {
if (customerEditSaving) {
return;
}
setCustomerEditSaving(true);
try {
await saveCustomerProfile({ ...payload, id: customer.id });
toast.success("已保存");
void load();
setCustomerEditOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setCustomerEditSaving(false);
}
}}
/>
{company ? (
<CompanyEditDialog
open={companyEditOpen}
onOpenChange={setCompanyEditOpen}
company={company}
onSaved={() => {
void load();
}}
/>
) : null}
</div>
);
}
function RelatedTicketsSection({ conversation }: { conversation: AgentConversation }) {
const [tickets, setTickets] = useState<TicketItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
async function loadTickets() {
setLoading(true);
try {
const data = await fetchTickets({
conversationId: conversation.id,
page: 1,
limit: 5,
});
if (!cancelled) {
setTickets(Array.isArray(data.results) ? data.results : []);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载关联工单失败");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadTickets();
return () => {
cancelled = true;
};
}, [conversation.id]);
return (
<section className="space-y-2 border-t pt-2">
<SectionHeading></SectionHeading>
{loading ? (
<p className="text-sm text-muted-foreground"></p>
) : tickets.length > 0 ? (
<div className="space-y-2">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/tickets/detail?id=${ticket.id}`}
target="_blank"
rel="noreferrer"
className="block rounded-lg border border-border bg-background px-3 py-2 transition-colors hover:bg-muted/40"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{ticket.title}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{ticket.ticketNo}
</div>
</div>
<TicketPriorityBadge priority={ticket.priority} priorityName={ticket.priorityName} />
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<TicketStatusBadge status={ticket.status} />
<span className="text-xs text-muted-foreground">
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "—"}
</span>
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</section>
);
}
type CompanyEditDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
company: AdminCompany;
onSaved: () => void;
};
function CompanyEditDialog({
open,
onOpenChange,
company,
onSaved,
}: CompanyEditDialogProps) {
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [remark, setRemark] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
return;
}
setName(company.name);
setCode(company.code);
setRemark(company.remark);
}, [open, company]);
const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("公司名称不能为空");
return;
}
setSaving(true);
try {
await updateCompany({
id: company.id,
name: trimmedName,
code: code.trim(),
remark: remark.trim(),
});
toast.success("已保存");
onSaved();
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-1">
<Field orientation="vertical">
<FieldLabel htmlFor="co-name"></FieldLabel>
<FieldContent>
<Input id="co-name" value={name} onChange={(e) => setName(e.target.value)} />
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="co-code"></FieldLabel>
<FieldContent>
<Input id="co-code" value={code} onChange={(e) => setCode(e.target.value)} />
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="co-remark"></FieldLabel>
<FieldContent>
<Textarea
id="co-remark"
value={remark}
onChange={(e) => setRemark(e.target.value)}
rows={3}
/>
</FieldContent>
</Field>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? "保存中…" : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,123 @@
"use client"
import { UserIcon } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { ScrollArea } from "@/components/ui/scroll-area"
import { formatDateTime } from "@/lib/utils"
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
import {
IMConversationStatus,
IMConversationStatusLabels,
} from "@/lib/generated/enums"
import { getEnumLabel } from "@/lib/enums"
function getStatusVariant(status: number) {
switch (status) {
case IMConversationStatus.AIServing:
return "bg-violet-500/15 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300"
case IMConversationStatus.Pending:
return "bg-blue-500/15 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
case IMConversationStatus.Active:
return "bg-emerald-500/15 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-300"
case IMConversationStatus.Closed:
return "bg-muted text-muted-foreground"
default:
return "bg-muted text-muted-foreground"
}
}
type ConversationListProps = {
onAfterSelect?: () => void
}
export function ConversationList({ onAfterSelect }: ConversationListProps) {
const conversations = useAgentConversationsStore((state) => state.conversations)
const loading = useAgentConversationsStore((state) => state.conversationsLoading)
const selectedId = useAgentConversationsStore((state) => state.selectedConversationId)
const selectConversation = useAgentConversationsStore((state) => state.selectConversation)
return (
<ScrollArea className="flex-1 bg-transparent">
<div className="divide-y divide-border">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
...
</div>
) : conversations.length > 0 ? (
conversations.map((conversation) => {
const isSelected = selectedId === conversation.id
return (
<div
key={conversation.id}
className={`cursor-pointer px-2.5 py-1.5 transition-colors hover:bg-muted/50 ${
isSelected ? "bg-muted/80" : ""
}`}
onClick={() => {
void selectConversation(conversation.id).then(
() => {
onAfterSelect?.()
},
() => {},
)
}}
>
<div className="overflow-hidden">
<div className="flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src="" />
<AvatarFallback className="bg-primary/10">
<UserIcon className="size-3.5 text-primary" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-medium text-sm leading-4">
{conversation.subject}
</span>
{conversation.agentUnreadCount > 0 ? (
<div className="flex size-4.5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{conversation.agentUnreadCount > 99
? "99+"
: conversation.agentUnreadCount}
</div>
) : null}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{conversation.lastMessageAt
? formatDateTime(conversation.lastMessageAt)
: "暂无时间"}
</div>
</div>
</div>
<div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
{conversation.lastMessageSummary || "暂无最新消息"}
</div>
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<span
className={`rounded px-1 py-0.5 ${getStatusVariant(
conversation.status
)}`}
>
{getEnumLabel(IMConversationStatusLabels, conversation.status)}
</span>
{conversation.externalSource ? (
<>
<span className="opacity-40">·</span>
<span className="truncate">{conversation.externalSource}</span>
</>
) : null}
</div>
</div>
</div>
)
})
) : (
<div className="p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</ScrollArea>
)
}
@@ -0,0 +1,215 @@
"use client"
import { CheckIcon, Loader2Icon, TagIcon } from "lucide-react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
addConversationTag,
removeConversationTag,
type AgentConversation,
type AgentConversationTag,
} from "@/lib/api/agent"
import { type TagTree } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
type TagNode = TagTree & {
depth: number
}
function flattenTagTree(nodes: TagTree[], depth = 0): TagNode[] {
const result: TagNode[] = []
nodes.forEach((item) => {
result.push({ ...item, depth })
if (item.children.length > 0) {
result.push(...flattenTagTree(item.children, depth + 1))
}
})
return result
}
function buildTagPathMap(
nodes: TagTree[],
parentPath = ""
): Map<number, string> {
const result = new Map<number, string>()
nodes.forEach((item) => {
const currentPath = parentPath ? `${parentPath} / ${item.name}` : item.name
result.set(item.id, currentPath)
if (item.children.length > 0) {
buildTagPathMap(item.children, currentPath).forEach((value, key) => {
result.set(key, value)
})
}
})
return result
}
type ConversationTagPickerProps = {
conversation: AgentConversation
availableTags: TagTree[]
loading?: boolean
onTagsChange: (tags: AgentConversationTag[]) => void
}
export function ConversationTagPicker({
conversation,
availableTags,
loading = false,
onTagsChange,
}: ConversationTagPickerProps) {
const [pendingTagId, setPendingTagId] = useState<number | null>(null)
const flattenedTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
const selectedTagIds = useMemo(
() => new Set((conversation.tags ?? []).map((item) => item.id)),
[conversation.tags]
)
async function handleToggle(tag: TagNode) {
if (pendingTagId !== null) {
return
}
const exists = selectedTagIds.has(tag.id)
const currentTags = conversation.tags ?? []
const nextTags = exists
? currentTags.filter((item) => item.id !== tag.id)
: [...currentTags, { id: tag.id, name: tag.name }]
setPendingTagId(tag.id)
try {
if (exists) {
await removeConversationTag({
conversationId: conversation.id,
tagId: tag.id,
})
} else {
await addConversationTag({
conversationId: conversation.id,
tagId: tag.id,
})
}
onTagsChange(nextTags)
toast.success(exists ? "已移除会话标签" : "已添加会话标签")
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新会话标签失败")
} finally {
setPendingTagId(null)
}
}
return (
<Popover>
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
aria-label="编辑会话标签"
/>
}
>
<TagIcon className="size-3.5 text-muted-foreground" />
</PopoverTrigger>
<PopoverContent
align="end"
className="w-72 p-0"
onClick={(event) => event.stopPropagation()}
>
<Command>
<CommandInput placeholder="搜索标签" />
<CommandList>
{loading ? <CommandEmpty>...</CommandEmpty> : null}
{!loading && flattenedTags.length === 0 ? (
<CommandEmpty></CommandEmpty>
) : null}
{!loading ? (
<CommandGroup heading="标签">
{flattenedTags.map((tag) => {
const checked = selectedTagIds.has(tag.id)
const pending = pendingTagId === tag.id
return (
<CommandItem
key={tag.id}
value={`${tag.id} ${tag.name} ${tag.remark}`}
disabled={pendingTagId !== null}
onSelect={() => void handleToggle(tag)}
>
{pending ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
<CheckIcon
className={cn(
"mr-2 size-4",
checked ? "opacity-100" : "opacity-0"
)}
/>
)}
<span
className="truncate"
style={{ paddingLeft: `${tag.depth * 12}px` }}
>
{tag.name}
</span>
</CommandItem>
)
})}
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
type ConversationTagBadgesProps = {
tags?: AgentConversationTag[]
availableTags?: TagTree[]
}
export function ConversationTagBadges({
tags,
availableTags = [],
}: ConversationTagBadgesProps) {
if (!tags || tags.length === 0) {
return null
}
const tagPathMap = buildTagPathMap(availableTags)
return (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => (
<Badge
key={tag.id}
variant="outline"
className="max-w-full px-2 text-[12px] font-normal"
>
<span className="break-all">
{tagPathMap.get(tag.id) ?? tag.name}
</span>
</Badge>
))}
</div>
)
}
@@ -0,0 +1,506 @@
"use client";
import {
ArrowRightLeftIcon,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
CircleUserRoundIcon,
CircleXIcon,
FilePlus2Icon,
Menu,
MoreHorizontalIcon,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog";
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime";
import {
agentConversationFilterOptions,
agentConversationSelectors,
type AgentConversationFilterKey,
useAgentConversationsStore,
} from "@/lib/stores/agent-conversations";
import { CreateTicketFromConversationDialog } from "../tickets/_components/create-ticket-from-conversation-dialog";
import { ChatPanel } from "./_components/chat-panel";
import { ConversationInfoPanel } from "./_components/conversation-info-panel";
import { ConversationList } from "./_components/conversation-list";
export default function ConversationsPage() {
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
const conversationFilter = useAgentConversationsStore(
(state) => state.conversationFilter,
);
const setConversationFilter = useAgentConversationsStore(
(state) => state.setConversationFilter,
);
const loadConversations = useAgentConversationsStore(
(state) => state.loadConversations,
);
const loadMessages = useAgentConversationsStore(
(state) => state.loadMessages,
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [infoPanelCollapsed, setInfoPanelCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileCustomerSheetOpen, setMobileCustomerSheetOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [closeOpen, setCloseOpen] = useState(false);
const [createTicketOpen, setCreateTicketOpen] = useState(false);
const sidebarPanelRef = useRef<PanelImperativeHandle | null>(null);
const infoPanelRef = useRef<PanelImperativeHandle | null>(null);
const filterContainerRef = useRef<HTMLDivElement | null>(null);
const filterMeasureRef = useRef<HTMLDivElement | null>(null);
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
useEffect(() => {
const container = filterContainerRef.current;
const measure = filterMeasureRef.current;
if (!container || !measure) {
return;
}
const updateFilterMode = () => {
setShowFilterDropdown(measure.scrollWidth > container.clientWidth);
};
updateFilterMode();
const observer = new ResizeObserver(() => {
updateFilterMode();
});
observer.observe(container);
observer.observe(measure);
return () => {
observer.disconnect();
};
}, []);
const currentFilterOption =
agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ??
agentConversationFilterOptions[0];
useEffect(() => {
void loadConversations().catch((error) => {
toast.error(error instanceof Error ? error.message : "加载会话列表失败");
});
}, [loadConversations, conversationFilter]);
async function handleConversationChanged(conversationId: number) {
await loadConversations();
await loadMessages(conversationId, {
forceLoading: false,
reset: false,
});
}
useAgentConversationRealtime();
const handleSidebarToggle = () => {
const panel = sidebarPanelRef.current;
if (!panel) {
setSidebarCollapsed((current) => !current);
return;
}
if (panel.isCollapsed()) {
panel.expand();
setSidebarCollapsed(false);
return;
}
panel.collapse();
setSidebarCollapsed(true);
};
const handleInfoPanelToggle = () => {
const panel = infoPanelRef.current;
if (!panel) {
setInfoPanelCollapsed((current) => !current);
return;
}
if (panel.isCollapsed()) {
panel.expand();
setInfoPanelCollapsed(false);
return;
}
panel.collapse();
setInfoPanelCollapsed(true);
};
const renderConversationSidebar = (opts?: { onListAfterSelect?: () => void }) => (
<div className="flex h-full min-h-0 flex-1 flex-col bg-inherit">
<div className="flex h-12.5 shrink-0 items-start justify-between gap-2 border-b border-border p-2">
<div ref={filterContainerRef} className="relative min-w-0 flex-1">
{showFilterDropdown ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
className="h-8.5 w-full min-w-0 justify-between gap-2 px-3 text-xs sm:text-sm"
/>
}
>
<span className="truncate">{currentFilterOption?.label ?? "筛选状态"}</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44 min-w-44">
<DropdownMenuRadioGroup
value={conversationFilter}
onValueChange={(value) =>
setConversationFilter(value as AgentConversationFilterKey)
}
>
{agentConversationFilterOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tabs
value={conversationFilter}
onValueChange={(value) =>
setConversationFilter(value as AgentConversationFilterKey)
}
className="min-w-0 flex-1 gap-0"
>
<TabsList
className="w-full min-w-0 justify-start"
>
{agentConversationFilterOptions.map((opt) => (
<TabsTrigger
key={opt.value}
value={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{opt.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<div
ref={filterMeasureRef}
className="pointer-events-none absolute whitespace-nowrap opacity-0"
aria-hidden="true"
>
<div className="inline-flex">
{agentConversationFilterOptions.map((opt) => (
<span
key={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{opt.label}
</span>
))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
>
<X className="size-4" />
</Button>
</div>
<ConversationList onAfterSelect={opts?.onListAfterSelect} />
</div>
);
const workspaceContent = (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-background text-foreground">
<div className="flex h-12.5 shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-1">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setMobileMenuOpen(true)}
>
<Menu className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="hidden lg:flex"
onClick={handleSidebarToggle}
>
{sidebarCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
</Button>
{conversation ? (
<>
<Avatar className="size-8 shrink-0 lg:size-9">
<AvatarImage src="" />
<AvatarFallback></AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="min-w-0 truncate font-medium leading-tight">
{conversation.subject}
</p>
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 text-[11px] ${
conversation.customerOnline
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-slate-200 bg-slate-100 text-slate-600"
}`}
>
<span
className={`size-1.5 rounded-full ${
conversation.customerOnline
? "bg-emerald-500"
: "bg-slate-400"
}`}
/>
{conversation.customerOnline ? "用户在线" : "用户离线"}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground sm:text-sm">
<span>{conversation.externalSource}</span>
<span className="text-muted-foreground/60"> / </span>
<span>{conversation.externalId}</span>
{conversation.customerId ? (
<>
<span className="text-muted-foreground/60"> / </span>
<span></span>
</>
) : null}
</p>
</div>
</>
) : (
<div className="min-w-0">
<p className="truncate font-medium text-[14px] leading-tight"></p>
<p className="mt-0.5 truncate text-[14px] text-muted-foreground sm:text-[14px] lg:hidden">
</p>
<p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block">
</p>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
disabled={!conversation}
aria-label="会话信息"
onClick={() => setMobileCustomerSheetOpen(true)}
>
<CircleUserRoundIcon className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" disabled={!conversation} />
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 min-w-44">
<DropdownMenuItem
onClick={() => setCreateTicketOpen(true)}
disabled={!conversation}
>
<FilePlus2Icon />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTransferOpen(true)}
disabled={!conversation || conversation.status !== 3}
>
<ArrowRightLeftIcon />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setCloseOpen(true)}
disabled={!conversation || conversation.status === 4}
>
<CircleXIcon />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="hidden lg:flex"
onClick={handleInfoPanelToggle}
aria-label={infoPanelCollapsed ? "展开会话信息" : "收起会话信息"}
>
{infoPanelCollapsed ? (
<ChevronLeft className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
</div>
</div>
<div className="flex min-h-0 w-full flex-1 overflow-hidden">
<ChatPanel />
</div>
</div>
);
return (
<div className="flex h-[calc(100dvh-var(--header-height))] min-h-0 w-full min-w-0 flex-col overflow-hidden lg:h-full">
{/* H5 无左侧导航:顶栏 h-12、left-0lg 起有 w-14 侧栏,与 layout 一致 */}
{mobileMenuOpen && (
<button
type="button"
aria-label="关闭会话列表"
className="fixed top-12 right-0 bottom-0 left-0 z-30 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
)}
<div
className={`fixed top-12 bottom-0 left-0 z-40 flex w-[min(22rem,calc(100vw-0.75rem))] max-w-[min(22rem,calc(100vw-0.75rem))] flex-col overflow-hidden border-r border-border bg-card text-card-foreground shadow-lg transition-transform duration-300 ease-out will-change-transform touch-manipulation overscroll-contain supports-[padding:max(0px)]:pb-[env(safe-area-inset-bottom)] lg:hidden ${
mobileMenuOpen ? "translate-x-0" : "-translate-x-full pointer-events-none"
}`}
aria-hidden={!mobileMenuOpen}
>
{renderConversationSidebar({
onListAfterSelect: () => setMobileMenuOpen(false),
})}
</div>
<div className="flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden lg:hidden">
{workspaceContent}
</div>
<div className="hidden min-h-0 w-full flex-1 overflow-hidden lg:flex">
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
panelRef={sidebarPanelRef}
defaultSize="20%"
minSize="10%"
maxSize="40%"
collapsedSize="0%"
collapsible
onResize={(panelSize: { asPercentage: number }) => {
setSidebarCollapsed(panelSize.asPercentage <= 1);
}}
className="min-h-0"
>
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-card text-card-foreground">
{renderConversationSidebar()}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50%" minSize="32%" className="min-h-0">
<div className="flex h-full min-h-0 flex-col overflow-hidden">
{workspaceContent}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
panelRef={infoPanelRef}
defaultSize="500px"
minSize="20%"
maxSize="40%"
collapsedSize="0%"
collapsible
onResize={(panelSize: { asPercentage: number }) => {
setInfoPanelCollapsed(panelSize.asPercentage <= 1);
}}
className="min-h-0"
>
<ConversationInfoPanel conversation={conversation} className="h-full" />
</ResizablePanel>
</ResizablePanelGroup>
</div>
<ConversationTransferDialog
open={transferOpen}
mode="transfer"
conversationId={conversation?.id ?? null}
onOpenChange={setTransferOpen}
onSuccess={async () => {
setTransferOpen(false);
if (conversation?.id) {
await handleConversationChanged(conversation.id);
}
}}
/>
<ConversationCloseDialog
open={closeOpen}
conversationId={conversation?.id ?? null}
onOpenChange={setCloseOpen}
onSuccess={async () => {
setCloseOpen(false);
if (conversation?.id) {
await handleConversationChanged(conversation.id);
}
}}
/>
<CreateTicketFromConversationDialog
open={createTicketOpen}
onOpenChange={setCreateTicketOpen}
conversation={
conversation
? {
id: conversation.id,
subject: conversation.subject,
customerId: conversation.customerId ?? 0,
lastMessageSummary: conversation.lastMessageSummary,
currentAssigneeId: conversation.currentAssigneeId,
}
: null
}
onSuccess={() => {
setCreateTicketOpen(false);
}}
/>
<Sheet open={mobileCustomerSheetOpen} onOpenChange={setMobileCustomerSheetOpen}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
showCloseButton
>
<ConversationInfoPanel
conversation={conversation}
variant="embedded"
className="min-h-0 flex-1"
/>
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,4 @@
"use client"
export { CustomerFormDialog, type CustomerFormDialogProps } from "@/components/customer-form-dialog"
export { CustomerFormDialog as EditDialog } from "@/components/customer-form-dialog"
@@ -0,0 +1,468 @@
"use client";
import {
BanIcon,
CheckCircle2Icon,
MoreHorizontalIcon,
PlusIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { type CustomerFormSavePayload } from "@/components/customer-form";
import { ListPagination } from "@/components/list-pagination";
import {
OptionCombobox,
type ComboboxOption,
} from "@/components/option-combobox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { type PageResult } from "@/lib/api/admin";
import { fetchCompanies, type AdminCompany } from "@/lib/api/company";
import {
deleteCustomer,
fetchCustomers,
saveCustomerProfile,
updateCustomerStatus,
type AdminCustomer,
} from "@/lib/api/customer";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
Gender,
GenderLabels,
Status,
StatusLabels,
} from "@/lib/generated/enums";
import { EditDialog } from "./_components/edit";
const listStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: item.label,
})),
] as const;
const genderOptions = [
{ value: "all", label: "全部性别" },
...getEnumOptions(GenderLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
] as const;
function getLabel(
value: string,
options: ReadonlyArray<{ value: string; label: string }>,
) {
return options.find((item) => item.value === value)?.label ?? "请选择";
}
export default function DashboardCustomersPage() {
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [genderFilterInput, setGenderFilterInput] = useState("all");
const [companyFilterInput, setCompanyFilterInput] = useState("0");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [genderFilter, setGenderFilter] = useState("all");
const [companyFilter, setCompanyFilter] = useState("0");
const [companyOptions, setCompanyOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部公司" },
]);
const [companyNameMap, setCompanyNameMap] = useState<Record<number, string>>(
{},
);
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<AdminCustomer | null>(null);
const [result, setResult] = useState<PageResult<AdminCustomer>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
useEffect(() => {
async function loadCompanies() {
try {
const data = await fetchCompanies({ status: 0, page: 1, limit: 500 });
const opts: ComboboxOption[] = [
{ value: "0", label: "全部公司" },
...data.results.map((item) => ({
value: String(item.id),
label: item.name,
})),
];
setCompanyOptions(opts);
const map: Record<number, string> = {};
data.results.forEach((item: AdminCompany) => {
map[item.id] = item.name;
});
setCompanyNameMap(map);
} catch {
// ignore
}
}
void loadCompanies();
}, []);
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchCustomers({
keyword: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : Number(statusFilter),
gender: genderFilter === "all" ? undefined : Number(genderFilter),
companyId: companyFilter === "0" ? undefined : Number(companyFilter),
page,
limit,
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客户列表失败");
} finally {
setLoading(false);
}
}, [companyFilter, genderFilter, keyword, limit, page, statusFilter]);
useEffect(() => {
void loadData();
}, [loadData]);
const companyFilterLabel = useMemo(() => {
return (
companyOptions.find((item) => item.value === companyFilterInput)?.label ??
"全部公司"
);
}, [companyFilterInput, companyOptions]);
function applyFilters() {
setKeyword(keywordInput);
setStatusFilter(statusFilterInput);
setGenderFilter(genderFilterInput);
setCompanyFilter(companyFilterInput);
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);
}
function openCreateDialog() {
setEditingItem(null);
setDialogOpen(true);
}
function openEditDialog(item: AdminCustomer) {
setEditingItem(item);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) return;
if (!open) setEditingItem(null);
setDialogOpen(open);
}
async function handleSave(payload: CustomerFormSavePayload) {
if (saving) return;
setSaving(true);
try {
await saveCustomerProfile(payload);
toast.success(
editingItem
? `已更新客户:${editingItem.name}`
: `已创建客户:${payload.name}`,
);
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客户失败");
} finally {
setSaving(false);
}
}
async function handleToggleStatus(item: AdminCustomer) {
setActionLoadingId(item.id);
try {
const nextStatus = item.status === 0 ? 1 : 0;
await updateCustomerStatus(item.id, nextStatus);
toast.success(`${nextStatus === 0 ? "启用" : "禁用"}${item.name}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败");
} finally {
setActionLoadingId(null);
}
}
async function handleDelete(item: AdminCustomer) {
setActionLoadingId(item.id);
try {
await deleteCustomer(item.id);
toast.success(`已删除客户:${item.name}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客户失败");
} finally {
setActionLoadingId(null);
}
}
function getGenderText(gender: number) {
return getEnumLabel(GenderLabels, gender as Gender);
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-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="姓名、手机、邮箱、公司、联系方式"
className="pl-9"
/>
</div>
<Select
value={genderFilterInput}
onValueChange={(v) => setGenderFilterInput(v ?? "all")}
>
<SelectTrigger className="w-full xl:w-28">
<SelectValue>
{getLabel(genderFilterInput, genderOptions)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{genderOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full xl:w-56">
<OptionCombobox
value={companyFilterInput}
options={companyOptions}
placeholder={companyFilterLabel}
searchPlaceholder="搜索公司名称"
onChange={(v) => setCompanyFilterInput(v)}
/>
</div>
<Select
value={statusFilterInput}
onValueChange={(v) => setStatusFilterInput(v ?? "all")}
>
<SelectTrigger className="w-full xl:w-28">
<SelectValue>
{getLabel(statusFilterInput, listStatusOptions)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{listStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-40"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.length === 0 && !loading ? (
<TableRow>
<TableCell
colSpan={8}
className="py-10 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
result.results.map((item) => {
const actionLoading = actionLoadingId === item.id;
return (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell className="text-muted-foreground">
{getGenderText(item.gender)}
</TableCell>
<TableCell className="text-muted-foreground">
{item.companyId > 0
? (companyNameMap[item.companyId] ??
String(item.companyId))
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{item.primaryMobile || "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{item.primaryEmail || "-"}
</TableCell>
<TableCell>
<Badge
variant={item.status === 0 ? "default" : "secondary"}
>
{item.status === 0 ? "启用" : "禁用"}
</Badge>
</TableCell>
<TableCell>
<ButtonGroup className="w-full justify-end">
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(item)}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
disabled={actionLoading}
/>
}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40 min-w-40"
>
<DropdownMenuItem
disabled={item.status === Status.Deleted}
onClick={() => void handleToggleStatus(item)}
>
{actionLoadingId === item.id ? (
"处理中..."
) : item.status === 0 ? (
<>
<BanIcon />
</>
) : (
<>
<CheckCircle2Icon />
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={item.status === Status.Deleted}
onClick={() => void handleDelete(item)}
>
<Trash2Icon />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit);
setPage(1);
}}
/>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSave={handleSave}
/>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
export default function DashboardHelpPage() {
return (
<DashboardPlaceholder
eyebrow="Help"
title="帮助中心骨架"
description="帮助中心用于沉淀产品说明、权限约定、渠道接入流程与常见问题。"
nextSteps={[
"汇总项目开发规范与后台使用说明。",
"补充第三方接入流程图和配置校验清单。",
"后续可接入 markdown 或文档中心能力。",
]}
/>
)
}
@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
import { BotIcon, SearchIcon, SparklesIcon } from "lucide-react";
import { toast } from "sonner";
import {
debugKnowledgeAnswer,
debugKnowledgeSearch,
type KnowledgeAnswerResponse,
type KnowledgeSearchResponse,
} from "@/lib/api/admin";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
type DebugPanelProps = {
knowledgeBaseId: number | null;
};
export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
const [question, setQuestion] = useState("");
const [topK, setTopK] = useState("5");
const [scoreThreshold, setScoreThreshold] = useState("0.2");
const [rerankLimit, setRerankLimit] = useState("5");
const [searching, setSearching] = useState(false);
const [answering, setAnswering] = useState(false);
const [searchResult, setSearchResult] = useState<KnowledgeSearchResponse | null>(null);
const [answerResult, setAnswerResult] = useState<KnowledgeAnswerResponse | null>(null);
async function handleSearch() {
if (!knowledgeBaseId) {
toast.error("请先选择知识库");
return;
}
if (!question.trim()) {
toast.error("请输入调试问题");
return;
}
setSearching(true);
try {
const data = await debugKnowledgeSearch({
knowledgeBaseIds: [knowledgeBaseId],
question: question.trim(),
topK: Number(topK) || undefined,
scoreThreshold: Number(scoreThreshold) || undefined,
rerankLimit: Number(rerankLimit) || undefined,
});
setSearchResult(data);
toast.success(`检索完成,命中 ${data.hitCount}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "检索失败");
} finally {
setSearching(false);
}
}
async function handleAnswer() {
if (!knowledgeBaseId) {
toast.error("请先选择知识库");
return;
}
if (!question.trim()) {
toast.error("请输入调试问题");
return;
}
setAnswering(true);
try {
const data = await debugKnowledgeAnswer({
knowledgeBaseIds: [knowledgeBaseId],
question: question.trim(),
topK: Number(topK) || undefined,
scoreThreshold: Number(scoreThreshold) || undefined,
rerankLimit: Number(rerankLimit) || undefined,
});
setAnswerResult(data);
toast.success(`问答完成,状态:${data.answerStatusName}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "问答失败");
} finally {
setAnswering(false);
}
}
return (
<div className="flex h-full flex-col gap-3 p-3">
<div className="space-y-3">
<Textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="输入问题,测试知识库召回和回答效果"
rows={5}
className="text-sm"
/>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="topk" className="text-xs">TopK</Label>
<Input id="topk" value={topK} onChange={(event) => setTopK(event.target.value)} placeholder="召回数量" className="h-8" />
</div>
<div className="space-y-1.5">
<Label htmlFor="threshold" className="text-xs"></Label>
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder="最低分数" className="h-8" />
</div>
<div className="space-y-1.5">
<Label htmlFor="rerank" className="text-xs"></Label>
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder="重排条数" className="h-8" />
</div>
</div>
<div className="flex gap-2">
<Button className="flex-1" variant="outline" onClick={() => void handleSearch()} disabled={searching}>
<SearchIcon className="mr-2 size-4" />
{searching ? "检索中..." : "调试检索"}
</Button>
<Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}>
<SparklesIcon className="mr-2 size-4" />
{answering ? "生成中..." : "调试问答"}
</Button>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3">
{answerResult ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<BotIcon className="size-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{answerResult.answerStatusName}</Badge>
<span className="text-xs text-muted-foreground">
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
</span>
</div>
<div className="rounded-md border bg-background p-3 whitespace-pre-wrap">
{answerResult.answer}
</div>
{answerResult.citations.length > 0 ? (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground"></div>
{answerResult.citations.map((citation) => (
<div
key={`${citation.documentId}-${citation.chunkNo}-${citation.sectionPath}`}
className="rounded-md border bg-muted/30 p-3"
>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-xs font-medium">
{getSearchResultLabel(citation)}
</div>
<Badge variant="outline">{citation.score.toFixed(4)}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{citation.sectionPath || citation.title || `Chunk #${citation.chunkNo}`}
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
{citation.snippet}
</div>
</div>
))}
</div>
) : null}
</CardContent>
</Card>
) : null}
{searchResult ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-muted-foreground">
{searchResult.hitCount} · {searchResult.latencyMs}ms
</div>
{searchResult.results.map((item) => (
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium">
{getSearchResultLabel(item)}
</div>
<Badge variant="outline">{item.score.toFixed(4)}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.sectionPath || item.title || `Chunk #${item.chunkNo}`}
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
{item.content}
</div>
</div>
))}
</CardContent>
</Card>
) : null}
</div>
</ScrollArea>
</div>
);
}
function getSearchResultLabel(item: {
faqQuestion?: string
faqId?: number
documentTitle?: string
documentId?: number
}) {
if (item.faqQuestion) {
return item.faqQuestion
}
if (item.documentTitle) {
return item.documentTitle
}
if (item.faqId && item.faqId > 0) {
return `FAQ ${item.faqId}`
}
return `文档 ${item.documentId ?? 0}`
}
@@ -0,0 +1,239 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { ProjectDialog } from "@/components/project-dialog"
import { ContentEditor } from "@/components/content-editor"
import { Button } from "@/components/ui/button"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
type KnowledgeDocument,
type CreateKnowledgeDocumentPayload,
fetchKnowledgeDocument,
} from "@/lib/api/admin"
import {
KnowledgeDocumentContentType,
} from "@/lib/generated/enums"
type DocumentEditDialogProps = {
open: boolean
saving: boolean
itemId: number | null
knowledgeBaseId: number | null
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
}
const emptyForm: EditForm = {
title: "",
contentType: KnowledgeDocumentContentType.Markdown,
content: "",
}
const knowledgeDocumentFormSchema = z.object({
title: z.string().trim().min(1, "标题不能为空").max(255, "标题最多255个字符"),
contentType: z.string().trim().min(1, "请选择内容类型"),
content: z.string().trim().min(1, "内容不能为空"),
})
type EditForm = z.infer<typeof knowledgeDocumentFormSchema>
const editFormResolver = zodResolver(knowledgeDocumentFormSchema as never) as Resolver<
z.input<typeof knowledgeDocumentFormSchema>,
undefined,
z.output<typeof knowledgeDocumentFormSchema>
>
function buildForm(item: KnowledgeDocument | null): EditForm {
if (!item) {
return emptyForm
}
return {
title: item.title,
contentType: item.contentType || KnowledgeDocumentContentType.Markdown,
content: item.content || "",
}
}
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeDocumentPayload {
return {
knowledgeBaseId,
title: form.title.trim(),
contentType: form.contentType,
content: form.content.trim(),
}
}
export function DocumentEditDialog({
open,
saving,
itemId,
knowledgeBaseId,
onOpenChange,
onSubmit,
}: DocumentEditDialogProps) {
if (!open || !knowledgeBaseId) {
return null
}
return (
<DocumentFormDialogBody
key={itemId ? `edit-${itemId}` : "create"}
itemId={itemId}
knowledgeBaseId={knowledgeBaseId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)
}
type DocumentFormDialogBodyProps = {
saving: boolean
itemId: number | null
knowledgeBaseId: number
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
}
function DocumentFormDialogBody({
saving,
itemId,
knowledgeBaseId,
onOpenChange,
onSubmit,
}: DocumentFormDialogBodyProps) {
const formId = "knowledge-document-edit-form"
const [loading, setLoading] = useState(false)
const form = useForm<
z.input<typeof knowledgeDocumentFormSchema>,
undefined,
z.output<typeof knowledgeDocumentFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
reset,
register,
setValue,
watch,
formState: { errors },
} = form
const contentType = watch("contentType")
const content = watch("content")
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm)
return
}
setLoading(true)
try {
const data = await fetchKnowledgeDocument(itemId)
reset(buildForm(data))
} catch (error) {
console.error("Failed to load knowledge document:", error)
} finally {
setLoading(false)
}
}
void loadDetail()
}, [itemId, reset])
async function onFormSubmit(values: EditForm) {
const payload = buildPayload({ ...values, contentType, content }, knowledgeBaseId)
await onSubmit(payload)
}
return (
<ProjectDialog
open={true}
onOpenChange={onOpenChange}
title={itemId ? "编辑文档" : "新建文档"}
size="xl"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="doc-title"></FieldLabel>
<FieldContent>
<Input
id="doc-title"
placeholder="文档标题"
aria-invalid={!!errors.title}
{...register("title")}
/>
<FieldError errors={[errors.title]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="doc-content"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="content"
render={({ field }) => (
<ContentEditor
value={{
mode:
contentType === KnowledgeDocumentContentType.HTML
? KnowledgeDocumentContentType.HTML
: KnowledgeDocumentContentType.Markdown,
raw: field.value ?? "",
}}
onChange={(next) => {
field.onChange(next.raw)
setValue("contentType", next.mode, {
shouldDirty: true,
shouldValidate: true,
})
}}
placeholder="请输入文档内容"
disabled={saving}
/>
)}
/>
<FieldError errors={[errors.content]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
)
}
@@ -0,0 +1,565 @@
"use client";
import {
FileTextIcon,
MoreHorizontalIcon,
PencilIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
buildKnowledgeDocumentIndex,
createKnowledgeDocument,
deleteKnowledgeDocument,
fetchKnowledgeDocuments,
updateKnowledgeDocument,
type CreateKnowledgeDocumentPayload,
type KnowledgeDocument,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
StatusLabels
} from "@/lib/generated/enums";
import { cn, formatDateTime } from "@/lib/utils";
import { DocumentEditDialog } from "./document-edit";
type DocumentListProps = {
knowledgeBaseId: number | null;
onActionStateChange?: (state: DocumentListActionState) => void;
};
export type DocumentListActionState = {
onRefresh: () => void;
onChangeViewMode: (mode: "list" | "grid") => void;
onCreate: () => void;
viewMode: "list" | "grid";
loading: boolean;
};
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels),
] as const;
function getIndexStatusBadgeVariant(status: string) {
switch (status) {
case KnowledgeDocumentIndexStatus.Indexed:
return "secondary" as const;
case KnowledgeDocumentIndexStatus.Failed:
return "destructive" as const;
default:
return "outline" as const;
}
}
function renderIndexStatusBadge(item: KnowledgeDocument) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName}
</Badge>
)
if (
item.indexStatus !== KnowledgeDocumentIndexStatus.Failed ||
!item.indexError
) {
return badge
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span className="inline-flex">{badge}</span>
</TooltipTrigger>
<TooltipContent align="start" className="max-w-sm whitespace-normal">
{item.indexError}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function getDocumentPreview(content: string, contentType: string) {
const preview =
contentType === "markdown"
? content
.replace(/[`*_>#-]/g, " ")
.replace(/\[(.*?)\]\((.*?)\)/g, "$1")
.replace(/\s+/g, " ")
.trim()
: content
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return preview || "暂无内容";
}
const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [indexStatusFilter, setIndexStatusFilter] = useState("all");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<KnowledgeDocument | null>(
null,
);
const [viewMode, setViewMode] = useState<"list" | "grid">(() => {
if (typeof window === "undefined") return "grid";
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY);
return saved === "list" || saved === "grid" ? saved : "grid";
});
const [documents, setDocuments] = useState<PageResult<KnowledgeDocument>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const loadData = useCallback(async () => {
if (!knowledgeBaseId) {
setDocuments({ results: [], page: { page: 1, limit: 20, total: 0 } });
setLoading(false);
return;
}
setLoading(true);
try {
const data = await fetchKnowledgeDocuments({
title: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
indexStatus: indexStatusFilter === "all" ? undefined : indexStatusFilter,
knowledgeBaseId,
limit: 1000,
});
setDocuments(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载文档失败");
} finally {
setLoading(false);
}
}, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode);
}, [viewMode]);
function handleStatusFilterChange(value: string | null) {
const newValue = value ?? "all";
setStatusFilterInput(newValue);
setStatusFilter(newValue);
}
function handleIndexStatusFilterChange(value: string | null) {
const newValue = value ?? "all";
setIndexStatusFilterInput(newValue);
setIndexStatusFilter(newValue);
}
function applyFilters() {
setKeyword(keywordInput);
setStatusFilter(statusFilterInput);
setIndexStatusFilter(indexStatusFilterInput);
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
const openCreateDialog = useCallback(() => {
setEditingItem(null);
setDialogOpen(true);
}, []);
useEffect(() => {
if (!onActionStateChange) {
return;
}
onActionStateChange({
onRefresh: () => void loadData(),
onChangeViewMode: setViewMode,
onCreate: openCreateDialog,
viewMode,
loading,
});
}, [onActionStateChange, loadData, openCreateDialog, viewMode, loading]);
function openEditDialog(item: KnowledgeDocument) {
setEditingItem(item);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateKnowledgeDocumentPayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateKnowledgeDocument({
id: editingItem.id,
...payload,
});
toast.success(`已更新文档:${editingItem.title}`);
} else {
await createKnowledgeDocument(payload);
toast.success(`已创建文档:${payload.title}`);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存文档失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeDocument) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
try {
await deleteKnowledgeDocument(item.id);
toast.success(`已删除文档:${item.title}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除文档失败");
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
}
}
async function handleBuildIndex(item: KnowledgeDocument) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try {
await buildKnowledgeDocumentIndex(item.id);
toast.success(`已重建索引:${item.title}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "重建索引失败");
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
}
}
if (!knowledgeBaseId) {
return (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<FileTextIcon className="mb-2 size-12 opacity-50" />
<p></p>
</div>
);
}
return (
<>
<div className="flex h-full min-h-0 flex-col">
<div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
<div className="flex gap-2">
<div className="relative flex-1">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="搜索文档标题"
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={statusFilterInput}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue>
{statusFilterInput === "all" ? "全部状态" : getEnumLabel(StatusLabels, Number(statusFilterInput))}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={indexStatusFilterInput}
onValueChange={handleIndexStatusFilterChange}
>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue>
{indexStatusFilterInput === "all"
? "全部索引状态"
: getEnumLabel(
KnowledgeDocumentIndexStatusLabels,
indexStatusFilterInput as KnowledgeDocumentIndexStatus
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{indexStatusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
<div className={viewMode === "grid" ? "p-2 space-y-1" : "p-2 space-y-0.5"}>
{documents.results.map((item) => (
viewMode === "grid" ? (
<ContextMenu key={item.id}>
<ContextMenuTrigger className="w-full">
<div
className="bg-background p-3 transition-colors hover:bg-accent w-full"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
{/* <FileTextIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{item.title}</div>
{renderIndexStatusBadge(item)}
</div>
<div className="mt-1 text-xs text-muted-foreground line-clamp-2">
{getDocumentPreview(item.content, item.contentType)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{item.createUserName || "-"}</span>
<span>{formatDateTime(item.createdAt)}</span>
<span className={cn(item.indexStatus === KnowledgeDocumentIndexStatus.Failed && "text-destructive")}>
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `已索引 ${formatDateTime(item.indexedAt)}`
: item.indexStatusName}
</span>
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-6"
/>
}
aria-label={`更多操作 ${item.title}`}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
variant="destructive"
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : (
<ContextMenu key={item.id}>
<ContextMenuTrigger className="w-full">
<div
className="flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full"
>
{/* <FileTextIcon className="size-4 shrink-0 text-muted-foreground" /> */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium">{item.title}</div>
{renderIndexStatusBadge(item)}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{getDocumentPreview(item.content, item.contentType)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `索引时间:${formatDateTime(item.indexedAt)}`
: item.indexError || item.indexStatusName}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatDateTime(item.createdAt)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-6"
/>
}
aria-label={`更多操作 ${item.title}`}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
variant="destructive"
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
))}
{!loading && documents.results.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
) : null}
</div>
</ScrollArea>
</div>
</div>
<DocumentEditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
knowledgeBaseId={knowledgeBaseId}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
);
}
@@ -0,0 +1,218 @@
"use client";
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod/v4";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
fetchKnowledgeFAQ,
type CreateKnowledgeFAQPayload,
type KnowledgeFAQ,
} from "@/lib/api/admin";
type FAQEditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
knowledgeBaseId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
const formSchema = z.object({
question: z.string().trim().min(1, "问题不能为空").max(500, "问题最多500个字符"),
answer: z.string().trim().min(1, "答案不能为空"),
similarQuestionsText: z.string(),
remark: z.string().trim().max(500, "备注最多500个字符"),
});
type EditForm = z.infer<typeof formSchema>;
const resolver = zodResolver(formSchema as never) as Resolver<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>;
const emptyForm: EditForm = {
question: "",
answer: "",
similarQuestionsText: "",
remark: "",
};
function buildForm(item: KnowledgeFAQ | null): EditForm {
if (!item) {
return emptyForm;
}
return {
question: item.question,
answer: item.answer,
similarQuestionsText: (item.similarQuestions ?? []).join("\n"),
remark: item.remark ?? "",
};
}
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
return {
knowledgeBaseId,
question: form.question.trim(),
answer: form.answer.trim(),
similarQuestions: form.similarQuestionsText
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
remark: form.remark.trim(),
};
}
export function FAQEditDialog({
open,
saving,
itemId,
knowledgeBaseId,
onOpenChange,
onSubmit,
}: FAQEditDialogProps) {
if (!open || !knowledgeBaseId) {
return null;
}
return (
<FAQEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
saving={saving}
itemId={itemId}
knowledgeBaseId={knowledgeBaseId}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type FAQEditDialogBodyProps = {
open: boolean;
saving: boolean;
itemId: number | null;
knowledgeBaseId: number;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
function FAQEditDialogBody({
open,
saving,
itemId,
knowledgeBaseId,
onOpenChange,
onSubmit,
}: FAQEditDialogBodyProps) {
const [loading, setLoading] = useState(false);
const formId = "knowledge-faq-edit-form";
const form = useForm<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>({
resolver,
defaultValues: emptyForm,
});
const {
handleSubmit,
reset,
register,
formState: { errors },
} = form;
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm);
return;
}
setLoading(true);
try {
const data = await fetchKnowledgeFAQ(itemId);
reset(buildForm(data));
} finally {
setLoading(false);
}
}
if (open) {
void loadDetail();
}
}, [itemId, open, reset]);
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, knowledgeBaseId));
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑FAQ" : "新建FAQ"}
allowFullscreen
size="xl"
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">...</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.question}>
<FieldLabel htmlFor="faq-question"></FieldLabel>
<FieldContent>
<Input id="faq-question" placeholder="请输入标准问题" {...register("question")} />
<FieldError errors={[errors.question]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.answer}>
<FieldLabel htmlFor="faq-answer"></FieldLabel>
<FieldContent>
<Textarea id="faq-answer" rows={8} placeholder="请输入FAQ答案" {...register("answer")} />
<FieldError errors={[errors.answer]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="faq-similar-questions"></FieldLabel>
<FieldContent>
<Textarea
id="faq-similar-questions"
rows={5}
placeholder={"一行一个相似问题"}
{...register("similarQuestionsText")}
/>
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="faq-remark"></FieldLabel>
<FieldContent>
<Textarea id="faq-remark" rows={3} placeholder="备注" {...register("remark")} />
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}
@@ -0,0 +1,402 @@
"use client";
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin";
type FAQImportDialogProps = {
open: boolean;
knowledgeBaseId: number | null;
importing: boolean;
onOpenChange: (open: boolean) => void;
onImportingChange: (importing: boolean) => void;
onImported: () => Promise<void>;
};
type ParsedFAQRow = {
rowNo: number;
question: string;
answer: string;
similarQuestions: string[];
remark: string;
};
type ParseResult = {
rows: ParsedFAQRow[];
warnings: string[];
};
const templateContent = [
"question,answer,similarQuestions,remark",
'"如何重置密码?","进入个人设置后点击重置密码。","忘记密码|密码重置在哪里","账号类FAQ"',
'"支持哪些接入渠道?","目前支持网站组件、企业微信等渠道接入。","有哪些渠道|支持什么渠道","渠道说明"',
].join("\n");
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
question: "question",
"标准问题": "question",
"问题": "question",
answer: "answer",
"答案": "answer",
similarquestions: "similarQuestions",
"similarQuestions": "similarQuestions",
"相似问": "similarQuestions",
"相似问题": "similarQuestions",
remark: "remark",
"备注": "remark",
};
function normalizeHeader(value: string) {
return value.trim().replace(/^\uFEFF/, "").toLowerCase();
}
function parseDelimitedText(input: string): string[][] {
const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
let delimiter = ",";
function pushCell() {
row.push(cell.trim());
cell = "";
}
function pushRow() {
if (row.length === 1 && row[0] === "" && rows.length === 0) {
row = [];
return;
}
if (row.some((item) => item !== "")) {
rows.push(row);
}
row = [];
}
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (!inQuotes && rows.length === 0 && row.length === 0 && cell.length > 0 && char === "\t") {
delimiter = "\t";
}
if (char === '"') {
if (inQuotes && next === '"') {
cell += '"';
i += 1;
continue;
}
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && char === delimiter) {
pushCell();
continue;
}
if (!inQuotes && char === "\n") {
pushCell();
pushRow();
continue;
}
cell += char;
}
if (cell.length > 0 || row.length > 0) {
pushCell();
pushRow();
}
return rows;
}
function parseSimilarQuestions(value: string) {
return value
.split(/\r?\n|\|/g)
.map((item) => item.trim())
.filter(Boolean);
}
function parseFAQFileContent(input: string): ParseResult {
const table = parseDelimitedText(input);
if (table.length === 0) {
throw new Error("文件内容为空");
}
const headerRow = table[0];
const headerMap = new Map<keyof Omit<ParsedFAQRow, "rowNo">, number>();
for (let index = 0; index < headerRow.length; index += 1) {
const header = acceptedHeaderMap[normalizeHeader(headerRow[index])];
if (header && !headerMap.has(header)) {
headerMap.set(header, index);
}
}
if (!headerMap.has("question") || !headerMap.has("answer")) {
throw new Error("导入模板缺少 question/answer 列");
}
const rows: ParsedFAQRow[] = [];
const warnings: string[] = [];
for (let index = 1; index < table.length; index += 1) {
const current = table[index];
const rowNo = index + 1;
const question = current[headerMap.get("question") ?? -1]?.trim() ?? "";
const answer = current[headerMap.get("answer") ?? -1]?.trim() ?? "";
const similarQuestionsRaw = current[headerMap.get("similarQuestions") ?? -1]?.trim() ?? "";
const remark = current[headerMap.get("remark") ?? -1]?.trim() ?? "";
if (!question && !answer && !similarQuestionsRaw && !remark) {
continue;
}
if (!question || !answer) {
warnings.push(`${rowNo} 行缺少问题或答案,已跳过`);
continue;
}
rows.push({
rowNo,
question,
answer,
similarQuestions: parseSimilarQuestions(similarQuestionsRaw),
remark,
});
}
return { rows, warnings };
}
function downloadTemplate() {
const blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "knowledge-faq-import-template.csv";
link.click();
URL.revokeObjectURL(url);
}
function buildPayload(row: ParsedFAQRow, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
return {
knowledgeBaseId,
question: row.question,
answer: row.answer,
similarQuestions: row.similarQuestions,
remark: row.remark,
};
}
export function FAQImportDialog({
open,
knowledgeBaseId,
importing,
onOpenChange,
onImportingChange,
onImported,
}: FAQImportDialogProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const [rows, setRows] = useState<ParsedFAQRow[]>([]);
const [warnings, setWarnings] = useState<string[]>([]);
const previewRows = useMemo(() => rows.slice(0, 5), [rows]);
function resetState() {
setFileName("");
setRows([]);
setWarnings([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
const content = await file.text();
const parsed = parseFAQFileContent(content);
setFileName(file.name);
setRows(parsed.rows);
setWarnings(parsed.warnings);
if (parsed.rows.length === 0) {
toast.error("没有可导入的FAQ记录");
} else {
toast.success(`已解析 ${parsed.rows.length} 条FAQ`);
}
} catch (error) {
resetState();
toast.error(error instanceof Error ? error.message : "解析导入文件失败");
}
}
async function handleImport() {
if (!knowledgeBaseId || rows.length === 0 || importing) {
return;
}
onImportingChange(true);
let successCount = 0;
const failedRows: string[] = [];
try {
for (const row of rows) {
try {
await createKnowledgeFAQ(buildPayload(row, knowledgeBaseId));
successCount += 1;
} catch (error) {
failedRows.push(
`${row.rowNo} 行:${error instanceof Error ? error.message : "导入失败"}`
);
}
}
await onImported();
if (successCount > 0) {
toast.success(`成功导入 ${successCount} 条FAQ`);
}
if (failedRows.length > 0) {
toast.error(`${failedRows.length} 条FAQ导入失败`);
setWarnings((current) => [...current, ...failedRows]);
return;
}
resetState();
onOpenChange(false);
} finally {
onImportingChange(false);
}
}
return (
<ProjectDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !importing) {
resetState();
}
onOpenChange(nextOpen);
}}
title="导入FAQ"
description="上传 CSV 文件批量导入 FAQ。必填列为 question、answersimilarQuestions 使用 | 或换行分隔。"
size="lg"
footer={
<>
<Button type="button" variant="outline" onClick={() => downloadTemplate()}>
<DownloadIcon className="size-4" />
</Button>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
</Button>
<Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}>
{importing ? "导入中..." : `开始导入${rows.length > 0 ? ` (${rows.length})` : ""}`}
</Button>
</>
}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="faq-import-file"></FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<Input
id="faq-import-file"
ref={fileInputRef}
type="file"
accept=".csv,text/csv,.txt"
onChange={(event) => void handleFileChange(event)}
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<FileUpIcon className="size-4" />
</Button>
</div>
<FieldDescription>
UTF-8 CSV
</FieldDescription>
</FieldContent>
</Field>
{fileName ? (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
{fileName}
</div>
) : null}
{warnings.length > 0 ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<div className="mb-2 flex items-center gap-2 font-medium">
<InfoIcon className="size-4" />
</div>
<ul className="space-y-1">
{warnings.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ul>
</div>
) : null}
<div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium">
</div>
{previewRows.length > 0 ? (
<ScrollArea className="max-h-80">
<div className="divide-y">
{previewRows.map((row) => (
<div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm">
<div>
<span className="text-muted-foreground"> {row.rowNo} </span>
</div>
<div>
<div className="font-medium">{row.question}</div>
<div className="mt-1 whitespace-pre-wrap text-muted-foreground">
{row.answer}
</div>
</div>
<div className="text-muted-foreground">
{row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : "无"}
</div>
<div className="text-muted-foreground">{row.remark || "无"}</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
5 FAQ
</div>
)}
</div>
</FieldGroup>
</ProjectDialog>
);
}
@@ -0,0 +1,453 @@
"use client";
import {
MoreHorizontalIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
buildKnowledgeFAQIndex,
createKnowledgeFAQ,
deleteKnowledgeFAQ,
fetchKnowledgeFAQs,
updateKnowledgeFAQ,
type CreateKnowledgeFAQPayload,
type KnowledgeFAQ,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
} from "@/lib/generated/enums";
import { formatDateTime } from "@/lib/utils";
import { FAQEditDialog } from "./faq-edit";
import { FAQImportDialog } from "./faq-import-dialog";
type FAQListProps = {
knowledgeBaseId: number | null;
onActionStateChange?: (state: FAQListActionState) => void;
};
export type FAQListActionState = {
onRefresh: () => void;
onCreate: () => void;
onImport: () => void;
loading: boolean;
importing: boolean;
};
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels),
] as const;
function getIndexStatusBadgeVariant(status: string) {
switch (status) {
case KnowledgeDocumentIndexStatus.Indexed:
return "secondary" as const;
case KnowledgeDocumentIndexStatus.Failed:
return "destructive" as const;
default:
return "outline" as const;
}
}
function renderIndexStatusBadge(item: KnowledgeFAQ) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName}
</Badge>
);
if (
item.indexStatus !== KnowledgeDocumentIndexStatus.Failed ||
!item.indexError
) {
return badge;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span className="inline-flex">{badge}</span>
</TooltipTrigger>
<TooltipContent align="start" className="max-w-sm whitespace-normal">
{item.indexError}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function FAQList({
knowledgeBaseId,
onActionStateChange,
}: FAQListProps) {
const [keywordInput, setKeywordInput] = useState("");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [indexStatusFilter, setIndexStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [importing, setImporting] = useState(false);
const [actionLoadingMap, setActionLoadingMap] = useState<
Record<number, { rebuildIndex: boolean; delete: boolean }>
>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<KnowledgeFAQ | null>(null);
const [result, setResult] = useState<PageResult<KnowledgeFAQ>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const loadData = useCallback(
async (options?: {
keyword?: string;
indexStatusFilter?: string;
page?: number;
limit?: number;
}) => {
const nextKeyword = options?.keyword ?? keyword;
const nextIndexStatusFilter =
options?.indexStatusFilter ?? indexStatusFilter;
const nextPage = options?.page ?? page;
const nextLimit = options?.limit ?? limit;
if (!knowledgeBaseId) {
setResult({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
return;
}
setLoading(true);
try {
const data = await fetchKnowledgeFAQs({
knowledgeBaseId,
question: nextKeyword.trim() || undefined,
indexStatus:
nextIndexStatusFilter === "all" ? undefined : nextIndexStatusFilter,
page: nextPage,
limit: nextLimit,
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载FAQ失败");
} finally {
setLoading(false);
}
},
[indexStatusFilter, keyword, knowledgeBaseId, limit, page],
);
useEffect(() => {
void loadData();
}, [knowledgeBaseId, loadData]);
useEffect(() => {
onActionStateChange?.({
onRefresh: () => void loadData(),
onCreate: () => {
setEditingItem(null);
setDialogOpen(true);
},
onImport: () => setImportDialogOpen(true),
loading,
importing,
});
}, [importing, loadData, loading, onActionStateChange]);
function applyFilters() {
const nextKeyword = keywordInput;
const nextIndexStatusFilter = indexStatusFilterInput;
setKeyword(nextKeyword);
setIndexStatusFilter(nextIndexStatusFilter);
setPage(1);
void loadData({
keyword: nextKeyword,
indexStatusFilter: nextIndexStatusFilter,
page: 1,
});
}
async function handleSubmit(payload: CreateKnowledgeFAQPayload) {
setSaving(true);
try {
if (editingItem) {
await updateKnowledgeFAQ({ id: editingItem.id, ...payload });
} else {
await createKnowledgeFAQ(payload);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
toast.success("FAQ已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存FAQ失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], delete: true },
}));
try {
await deleteKnowledgeFAQ(item.id);
toast.success("FAQ已删除");
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除FAQ失败");
} finally {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], delete: false },
}));
}
}
async function handleBuildIndex(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], rebuildIndex: true },
}));
try {
await buildKnowledgeFAQIndex(item.id);
toast.success("FAQ索引已重建");
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "重建FAQ索引失败");
} finally {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], rebuildIndex: false },
}));
}
}
if (!knowledgeBaseId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
FAQ知识库查看FAQ
</div>
);
}
return (
<>
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex items-center gap-2">
<div className="relative max-w-md flex-1">
<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={(event) => {
if (event.key === "Enter") {
applyFilters();
}
}}
placeholder="按问题搜索FAQ"
className="pl-9"
/>
</div>
<Select
value={indexStatusFilterInput}
onValueChange={(value) => setIndexStatusFilterInput(value ?? "all")}
>
<SelectTrigger className="w-40">
<SelectValue>
{indexStatusFilterInput === "all"
? "全部索引状态"
: getEnumLabel(
KnowledgeDocumentIndexStatusLabels,
indexStatusFilterInput as KnowledgeDocumentIndexStatus,
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{indexStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border">
<div className="h-full overflow-auto">
<table className="w-full min-w-max caption-bottom text-sm">
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
<TableRow key={item.id}>
<TableCell className="max-w-sm">
<div className="font-medium">{item.question}</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.answer}
</div>
</TableCell>
<TableCell>{renderIndexStatusBadge(item)}</TableCell>
<TableCell>
{Array.isArray(item.similarQuestions)
? item.similarQuestions.length
: 0}
</TableCell>
<TableCell>{formatDateTime(item.updatedAt)}</TableCell>
<TableCell className="w-20 text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingItem(item);
setDialogOpen(true);
}}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.question}`}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => void handleBuildIndex(item)}
>
<WrenchIcon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.rebuildIndex
? "重建中..."
: "重建索引"}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.delete
? "删除中..."
: "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-12 text-center text-sm text-muted-foreground"
>
FAQ
</TableCell>
</TableRow>
) : null}
</TableBody>
</table>
</div>
</div>
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
onPageChange={(nextPage: number) => {
setPage(nextPage);
void loadData({ page: nextPage });
}}
onLimitChange={(next: number) => {
setLimit(next);
setPage(1);
void loadData({ limit: next, page: 1 });
}}
/>
</div>
<FAQEditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
knowledgeBaseId={knowledgeBaseId}
onOpenChange={(open) => {
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}}
onSubmit={handleSubmit}
/>
<FAQImportDialog
open={importDialogOpen}
knowledgeBaseId={knowledgeBaseId}
importing={importing}
onOpenChange={setImportDialogOpen}
onImportingChange={setImporting}
onImported={loadData}
/>
</>
);
}
@@ -0,0 +1,536 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
fetchKnowledgeBase,
type CreateKnowledgeBasePayload,
type KnowledgeBase,
} from "@/lib/api/admin";
import {
KnowledgeBaseType,
KnowledgeBaseTypeLabels,
KnowledgeChunkProvider,
KnowledgeChunkProviderLabels,
KnowledgeAnswerMode,
KnowledgeAnswerModeLabels,
KnowledgeFallbackMode,
KnowledgeFallbackModeLabels,
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
type KnowledgeBaseEditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeBasePayload) => Promise<void>;
};
const emptyForm: EditForm = {
name: "",
description: "",
knowledgeType: KnowledgeBaseType.Document,
defaultTopK: "5",
defaultScoreThreshold: "0.2",
defaultRerankLimit: "10",
chunkProvider: KnowledgeChunkProvider.Structured,
chunkTargetTokens: "300",
chunkMaxTokens: "400",
chunkOverlapTokens: "40",
answerMode: String(KnowledgeAnswerMode.Strict),
fallbackMode: String(KnowledgeFallbackMode.NoAnswer),
remark: "",
};
const knowledgeBaseFormSchema = z.object({
name: z.string().trim().min(1, "名称不能为空").max(100, "名称最多100个字符"),
description: z.string().trim().max(500, "描述最多500个字符"),
knowledgeType: z.string().trim().min(1, "请选择知识库类型"),
defaultTopK: z.string().trim().min(1, "请输入TopK值"),
defaultScoreThreshold: z.string().trim().min(1, "请输入分数阈值"),
defaultRerankLimit: z.string().trim().min(1, "请输入重排序限制"),
chunkProvider: z.string().trim().min(1, "请选择分块策略"),
chunkTargetTokens: z.string().trim().min(1, "请输入目标 token 数"),
chunkMaxTokens: z.string().trim().min(1, "请输入最大 token 数"),
chunkOverlapTokens: z.string().trim().min(1, "请输入重叠 token 数"),
answerMode: z.string().trim().min(1, "请选择回答模式"),
fallbackMode: z.string().trim().min(1, "请选择回退模式"),
remark: z.string().trim().max(500, "备注最多500个字符"),
});
type EditForm = z.infer<typeof knowledgeBaseFormSchema>;
const editFormResolver = zodResolver(
knowledgeBaseFormSchema as never,
) as Resolver<
z.input<typeof knowledgeBaseFormSchema>,
undefined,
z.output<typeof knowledgeBaseFormSchema>
>;
function buildForm(item: KnowledgeBase | null): EditForm {
if (!item) {
return emptyForm;
}
return {
name: item.name,
description: item.description || "",
knowledgeType: item.knowledgeType || KnowledgeBaseType.Document,
defaultTopK: String(item.defaultTopK),
defaultScoreThreshold: String(item.defaultScoreThreshold),
defaultRerankLimit: String(item.defaultRerankLimit),
chunkProvider: item.chunkProvider,
chunkTargetTokens: String(item.chunkTargetTokens),
chunkMaxTokens: String(item.chunkMaxTokens),
chunkOverlapTokens: String(item.chunkOverlapTokens),
answerMode: String(item.answerMode),
fallbackMode: String(item.fallbackMode),
remark: item.remark || "",
};
}
function buildPayload(form: EditForm): CreateKnowledgeBasePayload {
return {
name: form.name.trim(),
description: form.description.trim(),
knowledgeType: form.knowledgeType,
defaultTopK: Number(form.defaultTopK),
defaultScoreThreshold: Number(form.defaultScoreThreshold),
defaultRerankLimit: Number(form.defaultRerankLimit),
chunkProvider: form.chunkProvider,
chunkTargetTokens: Number(form.chunkTargetTokens),
chunkMaxTokens: Number(form.chunkMaxTokens),
chunkOverlapTokens: Number(form.chunkOverlapTokens),
answerMode: Number(form.answerMode),
fallbackMode: Number(form.fallbackMode),
remark: form.remark.trim(),
};
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: KnowledgeBaseEditDialogProps) {
if (!open) {
return null;
}
return (
<KnowledgeBaseFormDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
itemId={itemId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type KnowledgeBaseFormDialogBodyProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeBasePayload) => Promise<void>;
};
function KnowledgeBaseFormDialogBody({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: KnowledgeBaseFormDialogBodyProps) {
const formId = "knowledge-base-edit-form";
const [loading, setLoading] = useState(false);
const form = useForm<
z.input<typeof knowledgeBaseFormSchema>,
undefined,
z.output<typeof knowledgeBaseFormSchema>
>({
resolver: editFormResolver,
defaultValues: buildForm(null),
});
const {
control,
handleSubmit,
reset,
register,
watch,
formState: { errors },
} = form;
const knowledgeType = watch("knowledgeType");
const isFAQKnowledgeBase = knowledgeType === KnowledgeBaseType.FAQ;
useEffect(() => {
if (!open) {
return;
}
if (itemId === null) {
reset(buildForm(null));
return;
}
let cancelled = false;
async function loadItem() {
try {
setLoading(true);
const data = await fetchKnowledgeBase(itemId!);
if (!cancelled) {
reset(buildForm(data));
}
} catch (error) {
if (!cancelled) {
console.error("Failed to load knowledge base:", error);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadItem();
return () => {
cancelled = true;
};
}, [open, itemId, reset]);
async function onFormSubmit(values: EditForm) {
const payload = buildPayload(values);
await onSubmit(payload);
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑知识库" : "新建知识库"}
size="lg"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving || loading}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">...</div>
</div>
) : (
<form
id={formId}
onSubmit={handleSubmit(onFormSubmit)}
className="space-y-4"
>
<Field data-invalid={!!errors.knowledgeType}>
<FieldLabel htmlFor="kb-knowledge-type"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="knowledgeType"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={getEnumOptions(KnowledgeBaseTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))}
placeholder="选择知识库类型"
searchPlaceholder="搜索知识库类型"
emptyText="没有匹配的知识库类型"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.knowledgeType]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="kb-name"></FieldLabel>
<FieldContent>
<Input
id="kb-name"
placeholder="知识库名称"
aria-invalid={!!errors.name}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="kb-description"></FieldLabel>
<FieldContent>
<Textarea
id="kb-description"
placeholder="知识库描述"
rows={3}
aria-invalid={!!errors.description}
{...register("description")}
/>
<FieldError errors={[errors.description]} />
</FieldContent>
</Field>
{!isFAQKnowledgeBase ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.chunkProvider}>
<FieldLabel htmlFor="kb-chunk-provider"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="chunkProvider"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={getEnumOptions(KnowledgeChunkProviderLabels)
.filter((option) => option.value !== KnowledgeChunkProvider.FAQ)
.map((option) => ({
value: String(option.value),
label: option.label,
}))}
placeholder="选择分块策略"
searchPlaceholder="搜索分块策略"
emptyText="没有匹配的分块策略"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.chunkProvider]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkTargetTokens}>
<FieldLabel htmlFor="kb-chunk-target-tokens">
Token
</FieldLabel>
<FieldContent>
<Input
id="kb-chunk-target-tokens"
type="number"
min="1"
max="2000"
aria-invalid={!!errors.chunkTargetTokens}
{...register("chunkTargetTokens")}
/>
<FieldError errors={[errors.chunkTargetTokens]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkMaxTokens}>
<FieldLabel htmlFor="kb-chunk-max-tokens"> Token</FieldLabel>
<FieldContent>
<Input
id="kb-chunk-max-tokens"
type="number"
min="1"
max="4000"
aria-invalid={!!errors.chunkMaxTokens}
{...register("chunkMaxTokens")}
/>
<FieldError errors={[errors.chunkMaxTokens]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkOverlapTokens}>
<FieldLabel htmlFor="kb-chunk-overlap-tokens">
Token
</FieldLabel>
<FieldContent>
<Input
id="kb-chunk-overlap-tokens"
type="number"
min="0"
max="500"
aria-invalid={!!errors.chunkOverlapTokens}
{...register("chunkOverlapTokens")}
/>
<FieldError errors={[errors.chunkOverlapTokens]} />
</FieldContent>
</Field>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.defaultTopK}>
<FieldLabel htmlFor="kb-default-top-k">TopK</FieldLabel>
<FieldContent>
<Input
id="kb-default-top-k"
type="number"
min="1"
max="100"
aria-invalid={!!errors.defaultTopK}
{...register("defaultTopK")}
/>
<FieldError errors={[errors.defaultTopK]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.defaultScoreThreshold}>
<FieldLabel htmlFor="kb-default-score-threshold">
</FieldLabel>
<FieldContent>
<Input
id="kb-default-score-threshold"
type="number"
min="0"
max="1"
step="0.1"
aria-invalid={!!errors.defaultScoreThreshold}
{...register("defaultScoreThreshold")}
/>
<FieldError errors={[errors.defaultScoreThreshold]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.defaultRerankLimit}>
<FieldLabel htmlFor="kb-default-rerank-limit">
</FieldLabel>
<FieldContent>
<Input
id="kb-default-rerank-limit"
type="number"
min="0"
max="100"
aria-invalid={!!errors.defaultRerankLimit}
{...register("defaultRerankLimit")}
/>
<FieldError errors={[errors.defaultRerankLimit]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.answerMode}>
<FieldLabel htmlFor="kb-answer-mode"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="answerMode"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
id="kb-answer-mode"
aria-invalid={!!errors.answerMode}
>
<SelectValue placeholder="选择回答模式">
{field.value
? getEnumLabel(
KnowledgeAnswerModeLabels,
Number(field.value),
)
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{getEnumOptions(KnowledgeAnswerModeLabels).map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.answerMode]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.fallbackMode}>
<FieldLabel htmlFor="kb-fallback-mode">退</FieldLabel>
<FieldContent>
<Controller
control={control}
name="fallbackMode"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
id="kb-fallback-mode"
aria-invalid={!!errors.fallbackMode}
>
<SelectValue placeholder="选择回退模式">
{field.value
? getEnumLabel(
KnowledgeFallbackModeLabels,
Number(field.value),
)
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{getEnumOptions(KnowledgeFallbackModeLabels).map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.fallbackMode]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="kb-remark"></FieldLabel>
<FieldContent>
<Textarea
id="kb-remark"
placeholder="备注信息"
rows={2}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}
@@ -0,0 +1,527 @@
"use client";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
CircleHelpIcon,
FileTextIcon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
createKnowledgeBase,
deleteKnowledgeBase,
fetchKnowledgeBases,
rebuildKnowledgeBaseIndex,
updateKnowledgeBase,
updateKnowledgeBaseSort,
type CreateKnowledgeBasePayload,
type KnowledgeBase,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { KnowledgeBaseType, StatusLabels } from "@/lib/generated/enums";
import { cn } from "@/lib/utils";
import { EditDialog } from "./knowledge-base-edit";
type KnowledgeBaseListProps = {
selectedKnowledgeBaseId: number | null;
onSelectKnowledgeBase: (knowledgeBase: KnowledgeBase | null) => void;
};
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
type SortableKnowledgeBaseCardProps = {
item: KnowledgeBase;
isSelected: boolean;
disabled: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
onRebuildIndex: () => void;
deleteLoadingId: number | null;
rebuildIndexLoadingId: number | null;
};
function SortableKnowledgeBaseCard({
item,
isSelected,
disabled,
onSelect,
onEdit,
onDelete,
onRebuildIndex,
deleteLoadingId,
rebuildIndexLoadingId,
}: SortableKnowledgeBaseCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<ContextMenu>
<ContextMenuTrigger
ref={setNodeRef}
style={style}
className={cn(
"group flex items-center gap-1 rounded mx-2 px-2 py-1.5 text-sm transition-colors hover:bg-accent cursor-pointer",
isSelected && "bg-accent text-accent-foreground",
isDragging && "bg-muted/60 shadow-sm opacity-80",
)}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
{...attributes}
{...listeners}
>
{item.knowledgeType === KnowledgeBaseType.FAQ ? (
<CircleHelpIcon className="size-4 shrink-0 text-muted-foreground" />
) : (
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate">{item.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{item.knowledgeType === "faq" ? item.faqCount : item.documentCount}
</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100"
/>
}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<PencilIcon className="mr-2 size-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRebuildIndex();
}}
>
<RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<PencilIcon className="mr-2 size-3.5" />
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onRebuildIndex();
}}
>
<RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
variant="destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
export function KnowledgeBaseList({
selectedKnowledgeBaseId,
onSelectKnowledgeBase,
}: KnowledgeBaseListProps) {
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [sorting, setSorting] = useState(false);
const [deleteLoadingId, setDeleteLoadingId] = useState<number | null>(null);
const [rebuildIndexLoadingId, setRebuildIndexLoadingId] = useState<
number | null
>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchKnowledgeBases({
name: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
limit: 1000,
});
setKnowledgeBases(data.results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载知识库失败");
} finally {
setLoading(false);
}
}, [keyword, statusFilter]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
if (
selectedKnowledgeBaseId === null &&
knowledgeBases.length > 0 &&
!loading
) {
onSelectKnowledgeBase(knowledgeBases[0]);
}
}, [selectedKnowledgeBaseId, knowledgeBases, loading, onSelectKnowledgeBase]);
function handleStatusFilterChange(value: string | null) {
setStatusFilterInput(value ?? "all");
}
function applyFilters() {
setKeyword(keywordInput);
setStatusFilter(statusFilterInput);
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
function openCreateDialog() {
setEditingItemId(null);
setDialogOpen(true);
}
function openEditDialog(item: KnowledgeBase) {
setEditingItemId(item.id);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItemId(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateKnowledgeBasePayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItemId) {
await updateKnowledgeBase({
id: editingItemId,
...payload,
});
const editingItem = knowledgeBases.find(
(item) => item.id === editingItemId,
);
toast.success(`已更新知识库:${editingItem?.name || payload.name}`);
} else {
await createKnowledgeBase(payload);
toast.success(`已创建知识库:${payload.name}`);
}
setDialogOpen(false);
setEditingItemId(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存知识库失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeBase) {
setDeleteLoadingId(item.id);
try {
await deleteKnowledgeBase(item.id);
toast.success(`已删除知识库:${item.name}`);
if (selectedKnowledgeBaseId === item.id) {
onSelectKnowledgeBase(null);
}
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除知识库失败");
} finally {
setDeleteLoadingId(null);
}
}
async function handleRebuildIndex(item: KnowledgeBase) {
setRebuildIndexLoadingId(item.id);
try {
await rebuildKnowledgeBaseIndex(item.id);
toast.success(`已开始重建知识库索引:${item.name}`);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "重建知识库索引失败",
);
} finally {
setRebuildIndexLoadingId(null);
}
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id || sorting) {
return;
}
const previousResults = knowledgeBases;
const oldIndex = previousResults.findIndex((item) => item.id === active.id);
const newIndex = previousResults.findIndex((item) => item.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const nextResults = arrayMove(previousResults, oldIndex, newIndex);
setKnowledgeBases(nextResults);
setSorting(true);
try {
await updateKnowledgeBaseSort(nextResults.map((item) => item.id));
toast.success("知识库排序已更新");
await loadData();
} catch (error) {
setKnowledgeBases(previousResults);
toast.error(
error instanceof Error ? error.message : "更新知识库排序失败",
);
} finally {
setSorting(false);
}
}
return (
<>
<div className="flex h-full flex-col border-r bg-muted/30">
<div className="flex flex-col gap-2 border-b bg-background p-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => void loadData()}
disabled={loading || sorting}
>
<RefreshCwIcon
className={loading || sorting ? "animate-spin" : "size-4"}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={openCreateDialog}
>
<PlusIcon className="size-4" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="搜索知识库"
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={statusFilterInput}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className="h-8 w-28 text-xs">
<SelectValue>
{statusFilterInput === "all"
? "全部状态"
: getEnumLabel(StatusLabels, Number(statusFilterInput))}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<ScrollArea className="flex-1">
<div className="py-1 space-y-0.5">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleDragEnd(event)}
>
<SortableContext
items={knowledgeBases.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{knowledgeBases.map((item) => (
<SortableKnowledgeBaseCard
key={item.id}
item={item}
isSelected={selectedKnowledgeBaseId === item.id}
disabled={loading || sorting}
onSelect={() => onSelectKnowledgeBase(item)}
onEdit={() => openEditDialog(item)}
onDelete={() => void handleDelete(item)}
onRebuildIndex={() => void handleRebuildIndex(item)}
deleteLoadingId={deleteLoadingId}
rebuildIndexLoadingId={rebuildIndexLoadingId}
/>
))}
</SortableContext>
</DndContext>
{!loading && knowledgeBases.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
) : null}
</div>
</ScrollArea>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItemId}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
);
}
@@ -0,0 +1,262 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import {
fetchKnowledgeRetrieveLog,
type KnowledgeRetrieveHit,
type KnowledgeRetrieveLogDetail,
} from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
type RetrieveLogDetailDrawerProps = {
open: boolean
retrieveLogId: number | null
onOpenChange: (open: boolean) => void
}
function safeParseJSON(value: string) {
if (!value) {
return null
}
try {
return JSON.parse(value) as Record<string, unknown>
} catch {
return null
}
}
function CitationList({ hits }: { hits: KnowledgeRetrieveHit[] }) {
const citations = hits.filter((item) => item.isCitation)
if (citations.length === 0) {
return <div className="text-sm text-muted-foreground"></div>
}
return (
<div className="space-y-3">
{citations.map((item) => (
<div key={item.id} className="rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium">{getHitSourceLabel(item)}</span>
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.sectionPath || item.title || "未记录章节"}
</div>
<div className="mt-2 text-sm leading-6 whitespace-pre-wrap text-foreground/90">
{item.snippet || "-"}
</div>
</div>
))}
</div>
)
}
export function RetrieveLogDetailDrawer({
open,
retrieveLogId,
onOpenChange,
}: RetrieveLogDetailDrawerProps) {
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<KnowledgeRetrieveLogDetail | null>(null)
const loadDetail = useCallback(async () => {
if (!retrieveLogId) {
setDetail(null)
return
}
setLoading(true)
try {
const data = await fetchKnowledgeRetrieveLog(retrieveLogId)
setDetail(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志详情失败")
} finally {
setLoading(false)
}
}, [retrieveLogId])
useEffect(() => {
if (open && retrieveLogId) {
void loadDetail()
}
}, [open, retrieveLogId, loadDetail])
const traceData = useMemo(() => safeParseJSON(detail?.log.traceData ?? ""), [detail?.log.traceData])
if (!open) {
return null
}
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
<DrawerContent className="max-w-3xl">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerDescription>
{detail?.log.question || (loading ? "加载中..." : "未找到检索日志")}
</DrawerDescription>
</DrawerHeader>
<ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6">
{!detail ? (
<div className="py-6 text-sm text-muted-foreground">
{loading ? "正在加载详情..." : "暂无详情数据"}
</div>
) : (
<div className="space-y-6 pb-6">
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{detail.log.knowledgeBaseName || `#${detail.log.knowledgeBaseId}`}</div>
</div>
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{formatDateTime(detail.log.createdAt)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground"> / </div>
<div className="mt-1 text-sm">{detail.log.channelName} / {detail.log.sceneName}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Request ID</div>
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
</div>
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.question || "-"}</div>
</div>
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.rewriteQuestion || "-"}</div>
</div>
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.answer || "-"}</div>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
<Metric label="Chunk Provider" value={detail.log.chunkProvider || "-"} mono />
<Metric label="Target Tokens" value={detail.log.chunkTargetTokens} />
<Metric label="Max Tokens" value={detail.log.chunkMaxTokens} />
<Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} />
<Metric label="Rerank" value={detail.log.rerankEnabled ? "已启用" : "未启用"} />
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-4">
<Metric label="回答状态" value={detail.log.answerStatusName} />
<Metric label="命中数" value={detail.log.hitCount} />
<Metric label="引用数" value={detail.log.citationCount} />
<Metric label="上下文 Chunk" value={detail.log.usedChunkCount} />
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
<Metric label="检索耗时" value={`${detail.log.retrieveMs} ms`} />
<Metric label="生成耗时" value={`${detail.log.generateMs} ms`} />
<Metric label="总耗时" value={`${detail.log.latencyMs} ms`} />
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
<Metric label="模型" value={detail.log.modelName || "-"} mono />
<Metric label="会话 ID" value={detail.log.sessionId || "-"} mono />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<CitationList hits={detail.hits} />
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"></h3>
<div className="text-xs text-muted-foreground">{detail.hits.length} </div>
</div>
<div className="space-y-3">
{detail.hits.map((item) => (
<div key={item.id} className="rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">#{item.rankNo}</Badge>
<span className="font-medium">{getHitSourceLabel(item)}</span>
<Badge variant={item.usedInAnswer ? "default" : "secondary"}>
{item.usedInAnswer ? "已入上下文" : "未入上下文"}
</Badge>
{item.isCitation ? <Badge></Badge> : null}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{item.sectionPath || item.title || "-"}</span>
<span>Chunk #{item.chunkNo}</span>
<span>Provider{item.provider || "-"}</span>
<span>Score{item.score.toFixed(4)}</span>
<span>Rerank{item.rerankScore ? item.rerankScore.toFixed(4) : "-"}</span>
</div>
<Separator className="my-3" />
<div className="text-sm leading-6 whitespace-pre-wrap text-foreground/90">
{item.snippet || "-"}
</div>
</div>
))}
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold">TraceData</h3>
<div className="rounded-lg border bg-muted/20 p-4">
<pre className="overflow-x-auto text-xs leading-6 text-muted-foreground">
{traceData ? JSON.stringify(traceData, null, 2) : detail.log.traceData || "-"}
</pre>
</div>
</section>
</div>
)}
</ScrollArea>
</DrawerContent>
</Drawer>
)
}
function getHitSourceLabel(item: KnowledgeRetrieveHit) {
if (item.faqQuestion) {
return item.faqQuestion
}
if (item.documentTitle) {
return item.documentTitle
}
if (item.faqId > 0) {
return `FAQ #${item.faqId}`
}
return `文档 #${item.documentId}`
}
function Metric({
label,
value,
mono = false,
}: {
label: string
value: string | number
mono?: boolean
}) {
return (
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className={`mt-1 text-sm ${mono ? "font-mono" : ""}`}>{String(value || value === 0 ? value : "-")}</div>
</div>
)
}
@@ -0,0 +1,292 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import {
fetchKnowledgeRetrieveLogs,
type KnowledgeRetrieveLog,
type PageResult,
} from "@/lib/api/admin"
import {
KnowledgeAnswerStatusLabels,
KnowledgeChunkProviderLabels,
KnowledgeRetrieveChannelLabels,
KnowledgeRetrieveSceneLabels,
} from "@/lib/generated/enums"
import { getEnumOptions } from "@/lib/enums"
import { formatDateTime } from "@/lib/utils"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
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 = {
knowledgeBaseId: number | null
}
const channelOptions = [
{ value: "all", label: "全部渠道" },
...getEnumOptions(KnowledgeRetrieveChannelLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const sceneOptions = [
{ value: "all", label: "全部场景" },
...getEnumOptions(KnowledgeRetrieveSceneLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const answerStatusOptions = [
{ value: "all", label: "全部回答状态" },
...getEnumOptions(KnowledgeAnswerStatusLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const providerOptions = [
{ value: "all", label: "全部切分策略" },
...getEnumOptions(KnowledgeChunkProviderLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const rerankOptions = [
{ value: "all", label: "全部 Rerank" },
{ value: "1", label: "已启用 Rerank" },
{ value: "0", label: "未启用 Rerank" },
]
function getAnswerStatusVariant(status: number): "default" | "secondary" | "outline" | "destructive" {
switch (status) {
case 1:
return "default"
case 2:
return "secondary"
case 3:
return "outline"
case 4:
return "destructive"
default:
return "outline"
}
}
export function RetrieveLogList({
knowledgeBaseId,
}: RetrieveLogListProps) {
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 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 : "加载检索日志失败")
} finally {
setLoading(false)
}
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
setPage(1)
setSelectedLogId(null)
setDetailOpen(false)
}, [knowledgeBaseId])
const emptyStateText = useMemo(() => {
if (!knowledgeBaseId) {
return "请选择一个知识库查看检索日志"
}
if (loading) {
return "正在加载检索日志..."
}
return "当前知识库还没有检索日志"
}, [knowledgeBaseId, loading])
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)
setDetailOpen(true)
}
if (!knowledgeBaseId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{emptyStateText}
</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="按问题关键字筛选"
className="pl-9"
/>
</div>
<OptionCombobox value={channel} options={channelOptions} placeholder="选择渠道" onChange={setChannel} />
<OptionCombobox value={scene} options={sceneOptions} placeholder="选择场景" onChange={setScene} />
<OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder="回答状态" onChange={setAnswerStatus} />
<OptionCombobox value={chunkProvider} options={providerOptions} placeholder="切分策略" onChange={setChunkProvider} />
<OptionCombobox value={rerankEnabled} options={rerankOptions} placeholder="Rerank" onChange={setRerankEnabled} />
<Button onClick={applyFilters}></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"></TableHead>
<TableHead></TableHead>
<TableHead className="w-28"></TableHead>
<TableHead className="w-24 text-right"></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"></TableHead>
<TableHead className="w-28 text-right"></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="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">
<span>{item.channelName}</span>
<span>{item.sceneName}</span>
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={getAnswerStatusVariant(item.answerStatus)}>
{item.answerStatusName}
</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 || "-"}</TableCell>
<TableCell>{item.rerankEnabled ? `是 (${item.rerankLimit})` : "否"}</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>
</div>
<RetrieveLogDetailDrawer
open={detailOpen}
retrieveLogId={selectedLogId}
onOpenChange={setDetailOpen}
/>
</>
)
}
@@ -0,0 +1,197 @@
"use client"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import type { KnowledgeBase } from "@/lib/api/admin"
import {
Bug,
DownloadIcon,
LayoutGridIcon,
LayoutListIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RefreshCwIcon
} from "lucide-react"
import { useState } from "react"
import { DebugPanel } from "./_components/debug-panel"
import { DocumentList, type DocumentListActionState } from "./_components/document-list"
import { FAQList, type FAQListActionState } from "./_components/faq-list"
import { KnowledgeBaseList } from "./_components/knowledge-base-list"
import { RetrieveLogList } from "./_components/retrieve-log-list"
export default function DashboardKnowledgeDocumentsPage() {
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | null>(null)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
const [activeTab, setActiveTab] = useState("documents")
const [documentActionState, setDocumentActionState] = useState<DocumentListActionState | null>(null)
const [faqActionState, setFAQActionState] = useState<FAQListActionState | null>(null)
const isFAQKnowledgeBase = selectedKnowledgeBase?.knowledgeType === "faq"
return (
<div className="flex h-[calc(100vh-4rem)]">
<div
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
sidebarCollapsed ? "w-0" : "w-80"
}`}
>
<KnowledgeBaseList
selectedKnowledgeBaseId={selectedKnowledgeBase?.id ?? null}
onSelectKnowledgeBase={setSelectedKnowledgeBase}
/>
</div>
<div className="relative shrink-0 bg-background">
<Button
variant="outline"
size="icon"
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => setSidebarCollapsed((value) => !value)}
aria-label={sidebarCollapsed ? "展开知识库列表" : "折叠知识库列表"}
>
{sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" />
) : (
<PanelLeftCloseIcon className="size-3.5" />
)}
</Button>
</div>
<div className="min-w-0 min-h-0 flex-1">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full min-h-0 gap-0">
<div className="border-b px-6 py-4">
<div className="flex items-center gap-2">
<TabsList>
<TabsTrigger value="documents">{isFAQKnowledgeBase ? "FAQ" : "文档"}</TabsTrigger>
<TabsTrigger value="retrieveLogs"></TabsTrigger>
</TabsList>
{activeTab === "documents" && !isFAQKnowledgeBase && documentActionState ? (
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={documentActionState.onRefresh}
disabled={documentActionState.loading}
aria-label="刷新文档"
>
<RefreshCwIcon className={documentActionState.loading ? "size-4 animate-spin" : "size-4"} />
</Button>
<Button
variant={documentActionState.viewMode === "list" ? "secondary" : "ghost"}
size="icon"
className="size-7"
onClick={() => documentActionState.onChangeViewMode("list")}
aria-label="列表布局"
>
<LayoutListIcon className="size-4" />
</Button>
<Button
variant={documentActionState.viewMode === "grid" ? "secondary" : "ghost"}
size="icon"
className="size-7"
onClick={() => documentActionState.onChangeViewMode("grid")}
aria-label="网格布局"
>
<LayoutGridIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setDebugPanelOpen(true)}
aria-label="打开调试面板"
>
<Bug className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={documentActionState.onCreate}
aria-label="新增文档"
>
<PlusIcon className="size-4" />
</Button>
</div>
) : null}
{activeTab === "documents" && isFAQKnowledgeBase && faqActionState ? (
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={faqActionState.onRefresh}
disabled={faqActionState.loading}
aria-label="刷新FAQ"
>
<RefreshCwIcon className={faqActionState.loading ? "size-4 animate-spin" : "size-4"} />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={faqActionState.onImport}
disabled={faqActionState.importing}
aria-label="导入FAQ"
>
<DownloadIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setDebugPanelOpen(true)}
aria-label="打开调试面板"
>
<Bug className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={faqActionState.onCreate}
aria-label="新增FAQ"
>
<PlusIcon className="size-4" />
</Button>
</div>
) : null}
</div>
</div>
<TabsContent value="documents" className="min-h-0 flex-1">
{isFAQKnowledgeBase ? (
<FAQList
knowledgeBaseId={selectedKnowledgeBase?.id ?? null}
onActionStateChange={setFAQActionState}
/>
) : (
<DocumentList
knowledgeBaseId={selectedKnowledgeBase?.id ?? null}
onActionStateChange={setDocumentActionState}
/>
)}
</TabsContent>
<TabsContent value="retrieveLogs" className="min-h-0 flex-1">
<RetrieveLogList
knowledgeBaseId={selectedKnowledgeBase?.id ?? null}
/>
</TabsContent>
</Tabs>
</div>
<Sheet open={debugPanelOpen} onOpenChange={setDebugPanelOpen}>
<SheetContent side="right" className="min-w-170">
<SheetHeader>
<SheetTitle>RAG </SheetTitle>
</SheetHeader>
<DebugPanel knowledgeBaseId={selectedKnowledgeBase?.id ?? null} />
</SheetContent>
</Sheet>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
"use client"
import type { CSSProperties, ReactNode } from "react"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { useAuth } from "@/components/auth-provider"
import { SiteHeader } from "@/components/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Card, CardContent } from "@/components/ui/card"
export default function DashboardLayout({
children,
}: {
children: ReactNode
}) {
const { ready, session } = useAuth()
const router = useRouter()
useEffect(() => {
if (ready && !session) {
router.replace("/login")
}
}, [ready, router, session])
if (!ready || !session) {
return (
<div className="flex min-h-screen items-center justify-center bg-[linear-gradient(160deg,#f3f4f6_0%,#fff7ed_45%,#ecfeff_100%)] p-6">
<Card className="w-full max-w-md border-0 bg-white/90 shadow-xl shadow-slate-200/60 backdrop-blur">
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<div className="size-10 animate-pulse rounded-full bg-primary/10" />
<div className="space-y-1">
<p className="text-base font-medium"></p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 54)",
"--header-height": "calc(var(--spacing) * 12)",
} as CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
{children}
</div>
</div>
</SidebarInset>
</SidebarProvider>
)
}
+392
View File
@@ -0,0 +1,392 @@
"use client";
import { Loader2Icon, PlugZapIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { JsonCodeEditor } from "@/components/json-code-editor";
import { JsonViewer } from "@/components/json-viewer";
import { OptionCombobox } from "@/components/option-combobox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Label } from "@/components/ui/label";
import {
callMCPTool,
listMCPServers,
listMCPTools,
testMCPConnection,
type MCPConnectionResult,
type MCPServerInfo,
type MCPToolCallResult,
type MCPToolInfo,
} from "@/lib/api/admin";
const defaultServerCode = "";
export default function MCPDashboardPage() {
const [serverCode, setServerCode] = useState(defaultServerCode);
const [servers, setServers] = useState<MCPServerInfo[]>([]);
const [connection, setConnection] = useState<MCPConnectionResult | null>(
null,
);
const [tools, setTools] = useState<MCPToolInfo[]>([]);
const [loadingServers, setLoadingServers] = useState(true);
const [testing, setTesting] = useState(false);
const [loadingTools, setLoadingTools] = useState(false);
const [callingTool, setCallingTool] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTool, setActiveTool] = useState<MCPToolInfo | null>(null);
const [argumentsText, setArgumentsText] = useState("{}");
const [toolResult, setToolResult] = useState<MCPToolCallResult | null>(null);
const [argumentsError, setArgumentsError] = useState<string | null>(null);
const serverOptions = useMemo(
() =>
servers.map((server) => ({
value: server.code,
label: server.enabled
? `${server.code} (${server.endpoint})`
: `${server.code} (disabled)`,
})),
[servers],
);
useEffect(() => {
async function loadServers() {
setLoadingServers(true);
try {
const result = await listMCPServers();
setServers(result);
const firstServer = result[0];
if (firstServer) {
setServerCode((current) => current || firstServer.code);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 MCP 服务失败",
);
} finally {
setLoadingServers(false);
}
}
void loadServers();
}, []);
async function handleTestConnection() {
setTesting(true);
try {
const result = await testMCPConnection(serverCode.trim());
setConnection(result);
toast.success("MCP 连接成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "MCP 连接失败");
} finally {
setTesting(false);
}
}
async function handleListTools() {
setLoadingTools(true);
try {
const result = await listMCPTools(serverCode.trim());
setTools(result);
toast.success(`已加载 ${result.length} 个工具`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载工具失败");
} finally {
setLoadingTools(false);
}
}
async function handleCallTool() {
if (!activeTool) {
toast.error("请先选择一个工具");
return;
}
if (argumentsError) {
toast.error("Arguments JSON 格式不合法");
return;
}
let parsedArguments: Record<string, unknown> = {};
try {
parsedArguments = argumentsText.trim()
? (JSON.parse(argumentsText) as Record<string, unknown>)
: {};
} catch {
toast.error("arguments 必须是合法 JSON");
return;
}
setCallingTool(true);
try {
const result = await callMCPTool({
serverCode: serverCode.trim(),
toolName: activeTool.name,
arguments: parsedArguments,
});
setToolResult(result);
toast.success(result.isError ? "工具返回错误结果" : "工具调用成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "工具调用失败");
} finally {
setCallingTool(false);
}
}
function openToolDrawer(tool: MCPToolInfo) {
setActiveTool(tool);
setToolResult(null);
if (tool.name === "lorem") {
setArgumentsText('{\n "wordCount": 8\n}');
} else if (tool.name === "ping") {
setArgumentsText('{\n "message": "hello from dashboard"\n}');
} else {
setArgumentsText("{}");
}
setDrawerOpen(true);
}
return (
<>
<div className="space-y-6 p-6">
{/* <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
setConnection(null)
setTools([])
setToolResult(null)
setActiveTool(null)
}}
>
<RefreshCwIcon className="mr-2 size-4" />
清空结果
</Button>
</div>
</div>
</div> */}
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border px-4 py-2">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="shrink-0 text-sm font-medium"></span>
<div className="min-w-[280px] max-w-[520px] flex-1">
<OptionCombobox
value={serverCode}
options={serverOptions}
placeholder="选择一个 MCP Server"
searchPlaceholder="搜索 serverCode"
emptyText="没有可用的 MCP Server"
disabled={loadingServers}
onChange={(value) => {
setServerCode(value);
setConnection(null);
setTools([]);
setToolResult(null);
setActiveTool(null);
}}
/>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
onClick={() => void handleTestConnection()}
disabled={testing || loadingServers || !serverCode}
>
{testing ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
<PlugZapIcon className="mr-2 size-4" />
)}
</Button>
<Button
variant="outline"
onClick={() => void handleListTools()}
disabled={loadingTools || loadingServers || !serverCode}
>
{loadingTools ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
<WrenchIcon className="mr-2 size-4" />
)}
</Button>
</div>
{connection ? (
<div className="w-full rounded-lg border bg-muted/30 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge></Badge>
<span className="font-medium">
{connection.serverName || "-"}
</span>
</div>
<div className="mt-3 grid gap-2 text-muted-foreground grid-cols-5">
<div>serverCode: {connection.serverCode}</div>
<div>protocol: {connection.protocol || "-"}</div>
<div className="md:col-span-2 break-all">
endpoint: {connection.endpoint}
</div>
<div>version: {connection.version || "-"}</div>
</div>
</div>
) : null}
</div>
{tools.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
</div>
) : (
<div className="border rounded-lg">
<div className="overflow-hidden">
<div className="grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_88px] gap-4 border-b bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<div></div>
<div></div>
<div className="text-right"></div>
</div>
{tools.map((tool) => (
<div
key={tool.name}
className="grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_88px] gap-4 border-b px-4 py-4 text-sm last:border-b-0"
>
<div className="font-medium">{tool.name}</div>
<div className="text-muted-foreground">
{tool.description || "-"}
</div>
<div className="text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openToolDrawer(tool)}
>
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
<Drawer open={drawerOpen} direction="right" onOpenChange={setDrawerOpen}>
<DrawerContent className="min-w-3xl">
<DrawerHeader>
<DrawerTitle>{activeTool?.name || "工具详情"}</DrawerTitle>
<DrawerDescription>
使 MCP Server
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 space-y-4 overflow-y-auto px-4 pb-4">
{activeTool ? (
<>
<div className="rounded-lg border p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{activeTool.name}</Badge>
{activeTool.title ? (
<span className="text-sm font-medium">
{activeTool.title}
</span>
) : null}
</div>
<p className="mt-3 text-sm text-muted-foreground">
{activeTool.description || "暂无描述"}
</p>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Input Schema
</p>
<JsonViewer value={activeTool.inputSchema} />
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Output Schema
</p>
<JsonViewer value={activeTool.outputSchema} />
</div>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="grid gap-2">
<Label htmlFor="tool-arguments">Arguments JSON</Label>
<JsonCodeEditor
value={argumentsText}
onChange={setArgumentsText}
onValidationChange={setArgumentsError}
/>
</div>
<Button
onClick={() => void handleCallTool()}
disabled={
callingTool ||
loadingServers ||
!serverCode ||
!!argumentsError
}
>
{callingTool ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
<WrenchIcon className="mr-2 size-4" />
)}
</Button>
{toolResult ? (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={
toolResult.isError ? "destructive" : "default"
}
>
{toolResult.isError ? "返回错误" : "调用成功"}
</Badge>
<span className="text-sm font-medium">
{toolResult.toolName}
</span>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Content
</p>
<JsonViewer value={toolResult.content} />
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Structured Content
</p>
<JsonViewer value={toolResult.structuredContent} />
</div>
</div>
) : null}
</div>
</>
) : null}
</div>
<DrawerFooter>
<Button variant="outline" onClick={() => setDrawerOpen(false)}>
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { RefreshCwIcon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent } from "@/components/ui/card"
import {
fetchDashboardOverview,
type DashboardOverview,
type DashboardRange,
} from "@/lib/api/dashboard"
import { SummaryCards } from "./_components/summary-cards"
import { TrendPanel } from "./_components/trend-panel"
import { TeamLoadPanel } from "./_components/team-load-panel"
import { AlertList } from "./_components/alert-list"
const rangeOptions: Array<{ value: DashboardRange; label: string }> = [
{ value: "today", label: "今天" },
{ value: "7d", label: "近 7 天" },
{ value: "30d", label: "近 30 天" },
]
function LoadingCards() {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index}>
<CardContent className="space-y-3 p-6">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
))}
</div>
)
}
export default function DashboardPage() {
const [range, setRange] = useState<DashboardRange>("7d")
const [data, setData] = useState<DashboardOverview | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const loadData = useCallback(
async (nextRange: DashboardRange, showRefreshing = false) => {
if (showRefreshing) {
setRefreshing(true)
} else {
setLoading(true)
}
try {
const result = await fetchDashboardOverview(nextRange)
setData(result)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载首页概览失败")
} finally {
setLoading(false)
setRefreshing(false)
}
},
[]
)
useEffect(() => {
void loadData(range)
}, [loadData, range])
return (
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-1 text-sm text-muted-foreground">
AI
{data ? `,更新于 ${data.generatedAt}` : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-xl border bg-muted/30 p-1">
{rangeOptions.map((item) => (
<Button
key={item.value}
variant={range === item.value ? "secondary" : "ghost"}
size="sm"
onClick={() => setRange(item.value)}
>
{item.label}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => void loadData(range, true)}
disabled={loading || refreshing}
>
<RefreshCwIcon className={refreshing ? "mr-2 size-4 animate-spin" : "mr-2 size-4"} />
</Button>
</div>
</div>
{loading && !data ? (
<LoadingCards />
) : data ? (
<>
<SummaryCards summary={data.summary} />
<TrendPanel
title="会话趋势"
description="观察新增与关闭会话变化,快速判断接待压力是否持续上升"
trend={data.conversationStats.trend}
distribution={data.conversationStats.statusDistribution}
/>
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<TeamLoadPanel agentStats={data.agentStats} />
<Card>
<CardContent className="grid gap-4 p-6 sm:grid-cols-2">
<div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"> AI Agent</div>
<div className="mt-2 text-3xl font-semibold">{data.aiStats.enabledAiAgents}</div>
</div>
<div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-3xl font-semibold">{data.aiStats.enabledChannels}</div>
</div>
<div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-3xl font-semibold">
{data.aiStats.todayKnowledgeRetrieves}
</div>
</div>
<div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-3xl font-semibold">
{data.aiStats.todayKnowledgeRetrieveFailRate.toFixed(1)}%
</div>
</div>
<div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"> Skill </div>
<div className="mt-2 text-3xl font-semibold">
{data.aiStats.todaySkillRunFailCount}
</div>
</div>
<div className="rounded-2xl border bg-muted/30 p-4">
<div className="text-sm text-muted-foreground"> AI </div>
<div className="mt-2 text-3xl font-semibold">
{data.aiStats.todayAiHandoffCount}
</div>
</div>
</CardContent>
</Card>
</div>
<AlertList alerts={data.alerts} />
</>
) : (
<Card>
<CardContent className="flex min-h-60 items-center justify-center p-6 text-sm text-muted-foreground">
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,229 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { KeyRoundIcon, RefreshCwIcon, RouteIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import {
fetchPermissions,
type AdminPermission,
type PageResult,
} from "@/lib/api/admin"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ListPagination } from "@/components/list-pagination"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const listStatusOptions = [
{ value: "all", label: "全部状态" },
{ value: String(Status.Ok), label: StatusLabels[Status.Ok] },
{ value: String(Status.Disabled), label: StatusLabels[Status.Disabled] },
{ value: String(Status.Deleted), label: StatusLabels[Status.Deleted] },
] as const
function getStatusLabel(
value: string,
options: ReadonlyArray<{ value: string; label: string }>
) {
return options.find((item) => item.value === value)?.label ?? "请选择状态"
}
export default function DashboardPermissionsPage() {
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 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 : "加载权限失败")
} finally {
setLoading(false)
}
}, [groupName, keyword, limit, page, statusFilter])
useEffect(() => {
void loadData()
}, [loadData])
function handleStatusFilterChange(value: string | null) {
setStatusFilterInput(value ?? "all")
}
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)
}
return (
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-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="按权限名称/编码筛选"
className="pl-9"
/>
</div>
<Input
value={groupNameInput}
onChange={(event) => setGroupNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按分组筛选"
className="w-full xl:w-48"
/>
<Select
value={statusFilterInput}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className="w-full xl:w-36">
<SelectValue>{getStatusLabel(statusFilterInput, listStatusOptions)}</SelectValue>
</SelectTrigger>
<SelectContent>
{listStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></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">{item.name}</div>
<div className="text-xs text-muted-foreground">{item.type}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{item.code}</Badge>
</TableCell>
<TableCell>{item.groupName}</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"}
>
{StatusLabels[item.status as Status] ?? String(item.status)}
</Badge>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
</div>
)
}
@@ -0,0 +1,304 @@
"use client";
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
type AdminQuickReply,
type CreateAdminQuickReplyPayload,
fetchQuickReply,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { Status, StatusLabels } from "@/lib/generated/enums";
type QuickReplyFormDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateAdminQuickReplyPayload) => Promise<void>;
};
const emptyForm: EditForm = {
groupName: "",
title: "",
content: "",
status: String(Status.Ok),
sortNo: "0",
};
const formStatusOptions = getEnumOptions(StatusLabels).filter(
(item) => Number(item.value) !== Status.Deleted,
);
const quickReplyFormSchema = z.object({
groupName: z.string().trim().min(1, "分组名称不能为空"),
title: z.string().trim().min(1, "标题不能为空"),
content: z.string().trim().min(1, "回复内容不能为空"),
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: "请选择状态",
}),
sortNo: z
.string()
.trim()
.min(1, "排序不能为空")
.regex(/^\d+$/, "排序值必须是大于等于 0 的整数"),
});
type EditForm = z.infer<typeof quickReplyFormSchema>;
const editFormResolver = zodResolver(quickReplyFormSchema as never) as Resolver<
z.input<typeof quickReplyFormSchema>,
undefined,
z.output<typeof quickReplyFormSchema>
>;
function getStatusLabel(value: string) {
return getEnumLabel(StatusLabels, Number(value) as Status);
}
function buildForm(item: AdminQuickReply | null): EditForm {
if (!item) {
return emptyForm;
}
return {
groupName: item.groupName,
title: item.title,
content: item.content,
status: String(item.status) as EditForm["status"],
sortNo: String(item.sortNo),
};
}
function buildPayload(form: EditForm): CreateAdminQuickReplyPayload {
return {
groupName: form.groupName.trim(),
title: form.title.trim(),
content: form.content.trim(),
status: Number(form.status) as Status,
sortNo: Number(form.sortNo),
};
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: QuickReplyFormDialogProps) {
if (!open) {
return null;
}
return (
<QuickReplyFormDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
itemId={itemId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type QuickReplyFormDialogBodyProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateAdminQuickReplyPayload) => Promise<void>;
};
function QuickReplyFormDialogBody({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: QuickReplyFormDialogBodyProps) {
const formId = "quick-reply-edit-form";
const [loading, setLoading] = useState(false);
const form = useForm<
z.input<typeof quickReplyFormSchema>,
undefined,
z.output<typeof quickReplyFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
const {
control,
handleSubmit,
reset,
register,
formState: { errors },
} = form;
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm);
return;
}
setLoading(true);
try {
const data = await fetchQuickReply(itemId);
reset(buildForm(data));
} catch (error) {
console.error("Failed to load quick reply:", error);
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset]);
async function onFormSubmit(values: EditForm) {
const payload = buildPayload(values);
await onSubmit(payload);
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑" : "新建"}
size="md"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.groupName}>
<FieldLabel htmlFor="quick-reply-group-name"></FieldLabel>
<FieldContent>
<Input
id="quick-reply-group-name"
placeholder="例如:售前、售后、催单"
aria-invalid={!!errors.groupName}
{...register("groupName")}
/>
<FieldError errors={[errors.groupName]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="quick-reply-title"></FieldLabel>
<FieldContent>
<Input
id="quick-reply-title"
placeholder="请输入快捷回复标题"
aria-invalid={!!errors.title}
{...register("title")}
/>
<FieldError errors={[errors.title]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="quick-reply-content"></FieldLabel>
<FieldContent>
<Textarea
id="quick-reply-content"
placeholder="请输入回复内容"
rows={6}
aria-invalid={!!errors.content}
{...register("content")}
/>
<FieldError errors={[errors.content]} />
</FieldContent>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.status}>
<FieldLabel htmlFor="quick-reply-status"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
modal={false}
>
<SelectTrigger
id="quick-reply-status"
className="w-full"
aria-invalid={!!errors.status}
>
<SelectValue>{getStatusLabel(field.value)}</SelectValue>
</SelectTrigger>
<SelectContent>
{formStatusOptions.map((option) => (
<SelectItem
key={String(option.value)}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.status]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.sortNo}>
<FieldLabel htmlFor="quick-reply-sort-no"></FieldLabel>
<FieldContent>
<Input
id="quick-reply-sort-no"
type="number"
min={0}
step={1}
placeholder="数字越大越靠前"
aria-invalid={!!errors.sortNo}
{...register("sortNo")}
/>
<FieldError errors={[errors.sortNo]} />
</FieldContent>
</Field>
</div>
</form>
)}
</ProjectDialog>
);
}
@@ -0,0 +1,380 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import {
FileTextIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "sonner"
import {
createQuickReply,
deleteQuickReply,
fetchQuickReplies,
updateQuickReply,
type AdminQuickReply,
type CreateAdminQuickReplyPayload,
type PageResult,
} from "@/lib/api/admin"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { EditDialog } from "./_components/edit"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { ListPagination } from "@/components/list-pagination"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const listStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: item.label,
})),
] as const
function getStatusLabel(
value: string,
options: ReadonlyArray<{ value: string; label: string }>
) {
return options.find((item) => item.value === value)?.label ?? "请选择状态"
}
export default function DashboardQuickRepliesPage() {
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 [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<AdminQuickReply | null>(null)
const [result, setResult] = useState<PageResult<AdminQuickReply>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchQuickReplies({
title: 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 : "加载快捷回复失败")
} finally {
setLoading(false)
}
}, [groupName, keyword, limit, page, statusFilter])
useEffect(() => {
void loadData()
}, [loadData])
function handleStatusFilterChange(value: string | null) {
setStatusFilterInput(value ?? "all")
}
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)
}
function openCreateDialog() {
setEditingItem(null)
setDialogOpen(true)
}
function openEditDialog(item: AdminQuickReply) {
setEditingItem(item)
setDialogOpen(true)
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return
}
if (!open) {
setEditingItem(null)
}
setDialogOpen(open)
}
async function handleSubmit(payload: CreateAdminQuickReplyPayload) {
if (saving) {
return
}
setSaving(true)
try {
if (editingItem) {
await updateQuickReply({
id: editingItem.id,
...payload,
})
toast.success(`已更新快捷回复:${editingItem.title}`)
} else {
await createQuickReply(payload)
toast.success(`已创建快捷回复:${payload.title}`)
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存快捷回复失败")
} finally {
setSaving(false)
}
}
async function handleToggleStatus(item: AdminQuickReply) {
setActionLoadingId(item.id)
try {
const nextStatus =
item.status === Status.Ok ? Status.Disabled : Status.Ok
await updateQuickReply({
id: item.id,
groupName: item.groupName,
title: item.title,
content: item.content,
sortNo: item.sortNo,
status: nextStatus,
})
toast.success(
`${nextStatus === Status.Ok ? "启用" : "禁用"}${item.title}`
)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
} finally {
setActionLoadingId(null)
}
}
async function handleDelete(item: AdminQuickReply) {
setActionLoadingId(item.id)
try {
await deleteQuickReply(item.id)
toast.success(`已删除快捷回复:${item.title}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除快捷回复失败")
} finally {
setActionLoadingId(null)
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-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="按标题筛选"
className="pl-9"
/>
</div>
<Input
value={groupNameInput}
onChange={(event) => setGroupNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按分组筛选"
className="w-full xl:w-48"
/>
<Select
value={statusFilterInput}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className="w-full xl:w-36">
<SelectValue>{getStatusLabel(statusFilterInput, listStatusOptions)}</SelectValue>
</SelectTrigger>
<SelectContent>
{listStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<FileTextIcon className="size-4" />
</div>
<div className="min-w-0">
<div className="font-medium">{item.title}</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.content}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{item.groupName}</Badge>
</TableCell>
<TableCell>
<Badge
variant={
item.status === Status.Ok ? "default" : "outline"
}
>
{getEnumLabel(StatusLabels, item.status as Status)}
</Badge>
</TableCell>
<TableCell>{item.sortNo}</TableCell>
<TableCell>{item.createdBy || "-"}</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(item)}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.title}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem onClick={() => void handleToggleStatus(item)}>
<RefreshCwIcon />
{actionLoadingId === item.id
? "处理中..."
: item.status === Status.Ok
? "禁用"
: "启用"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
)
}
@@ -0,0 +1,284 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { SearchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import type { AdminPermission, AdminRole } from "@/lib/api/admin";
type AssignPermissionsDrawerProps = {
open: boolean;
saving: boolean;
loading: boolean;
item: AdminRole | null;
permissions: AdminPermission[];
selectedPermissionIds: number[];
onOpenChange: (open: boolean) => void;
onSubmit: (permissionIds: number[]) => Promise<void>;
};
const assignPermissionsSchema = z.object({
permissionIds: z.array(z.number().int().positive()),
});
type AssignPermissionsForm = z.infer<typeof assignPermissionsSchema>;
const assignPermissionsResolver = zodResolver(
assignPermissionsSchema as never,
) as Resolver<
z.input<typeof assignPermissionsSchema>,
undefined,
z.output<typeof assignPermissionsSchema>
>;
function buildForm(selectedPermissionIds: number[]): AssignPermissionsForm {
return {
permissionIds: selectedPermissionIds,
};
}
export function AssignPermissionsDrawer({
open,
saving,
loading,
item,
permissions,
selectedPermissionIds,
onOpenChange,
onSubmit,
}: AssignPermissionsDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
{open ? (
<AssignPermissionsDrawerBody
key={item ? `assign-permissions-${item.id}` : "assign-permissions"}
saving={saving}
loading={loading}
item={item}
permissions={permissions}
selectedPermissionIds={selectedPermissionIds}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
) : null}
</Drawer>
);
}
type AssignPermissionsDrawerBodyProps = {
saving: boolean;
loading: boolean;
item: AdminRole | null;
permissions: AdminPermission[];
selectedPermissionIds: number[];
onOpenChange: (open: boolean) => void;
onSubmit: (permissionIds: number[]) => Promise<void>;
};
function AssignPermissionsDrawerBody({
saving,
loading,
item,
permissions,
selectedPermissionIds,
onOpenChange,
onSubmit,
}: AssignPermissionsDrawerBodyProps) {
const [keyword, setKeyword] = useState("");
const form = useForm<
z.input<typeof assignPermissionsSchema>,
undefined,
z.output<typeof assignPermissionsSchema>
>({
resolver: assignPermissionsResolver,
defaultValues: buildForm(selectedPermissionIds),
});
const {
control,
handleSubmit,
reset,
formState: { errors },
} = form;
useEffect(() => {
reset(buildForm(selectedPermissionIds));
}, [reset, selectedPermissionIds]);
const groupedPermissions = useMemo(() => {
const output = keyword.trim().toLowerCase();
const filtered = output
? permissions.filter((permission) =>
`${permission.name} ${permission.code} ${permission.groupName} ${permission.apiPath}`
.toLowerCase()
.includes(output),
)
: permissions;
const groups = new Map<string, AdminPermission[]>();
filtered.forEach((permission) => {
const groupName = permission.groupName || "default";
const list = groups.get(groupName) ?? [];
list.push(permission);
groups.set(groupName, list);
});
return Array.from(groups.entries()).sort(([left], [right]) =>
left.localeCompare(right, "zh-CN"),
);
}, [keyword, permissions]);
async function onFormSubmit(values: AssignPermissionsForm) {
await onSubmit(values.permissionIds);
}
return (
<DrawerContent className="min-w-3xl">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerDescription>
{item?.name || "-"} {item?.code ? `(${item.code})` : ""}
</DrawerDescription>
</DrawerHeader>
<form
className="flex h-full min-h-0 flex-col"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="flex-1 flex flex-col min-h-0 space-y-4 px-4 pb-4">
<Field data-invalid={!!errors.permissionIds} className="flex-1 flex flex-col min-h-0">
<FieldLabel></FieldLabel>
<FieldContent className="flex-1 min-h-0 flex flex-col">
<div className="relative mb-2">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索权限名称、编码、分组或接口"
className="pl-9"
disabled={loading}
/>
</div>
<Controller
control={control}
name="permissionIds"
render={({ field }) => {
const value = field.value || [];
return (
<div className="flex-1 min-h-0 space-y-4 overflow-y-auto rounded-xl border p-3">
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
</div>
) : groupedPermissions.length > 0 ? (
groupedPermissions.map(([groupName, list]) => (
<section key={groupName} className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline">{groupName}</Badge>
<span className="text-xs text-muted-foreground">
{list.length}
</span>
</div>
<div className="space-y-1">
{list.map((permission) => {
const checked = value.includes(permission.id);
return (
<label
key={permission.id}
className="flex cursor-pointer items-start gap-3 rounded-lg border border-transparent px-3 py-2 hover:bg-muted/50"
>
<Checkbox
checked={checked}
onCheckedChange={(nextChecked) => {
if (nextChecked) {
field.onChange([
...value,
permission.id,
]);
return;
}
field.onChange(
value.filter(
(currentId) =>
currentId !== permission.id,
),
);
}}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{permission.name}
</div>
<div className="text-sm text-muted-foreground">
{permission.code}
</div>
<div className="text-xs text-muted-foreground">
{permission.method || "ANY"}{" "}
{permission.apiPath || "-"}
</div>
</div>
<Badge
variant={
permission.status === 0
? "default"
: "secondary"
}
>
{permission.status === 1
? "禁用"
: "启用"}
</Badge>
</label>
);
})}
</div>
</section>
))
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
)}
</div>
);
}}
/>
<FieldError errors={[errors.permissionIds]} />
</FieldContent>
</Field>
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || loading || !item}>
{saving ? "保存中..." : "保存权限"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
</DrawerFooter>
</form>
</DrawerContent>
);
}
+358
View File
@@ -0,0 +1,358 @@
"use client"
import { useEffect, useState } from "react"
import type { CSSProperties } from "react"
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
GripVerticalIcon,
RefreshCwIcon,
ShieldCheckIcon,
ShieldIcon,
} from "lucide-react"
import { toast } from "sonner"
import {
assignRolePermissions,
fetchPermissions,
fetchRoleDetail,
fetchRoles,
type AdminPermission,
type AdminRole,
type PageResult,
updateRoleSort,
} from "@/lib/api/admin"
import { cn } from "@/lib/utils"
import { AssignPermissionsDrawer } from "./_components/assign-permissions"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Status } from "@/lib/generated/enums"
type SortableRoleRowProps = {
item: AdminRole
disabled: boolean
actionLoading: boolean
onAssignPermissions: (item: AdminRole) => void
}
function SortableRoleRow({
item,
disabled,
actionLoading,
onAssignPermissions,
}: SortableRoleRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<TableRow
ref={setNodeRef}
style={style}
className={cn(
isDragging && "relative z-10 bg-muted/60 shadow-sm",
!disabled && "cursor-move"
)}
>
<TableCell className="w-14">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon className="size-4 text-muted-foreground" />
</Button>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<ShieldCheckIcon className="size-4" />
</div>
<div className="font-medium">{item.name}</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
<ShieldIcon className="size-3" />
{item.code}
</Badge>
</TableCell>
<TableCell>
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
{item.status === Status.Ok ? "启用" : "禁用"}
</Badge>
{item.isSystem ? (
<Badge variant="outline" className="ml-2">
</Badge>
) : null}
</TableCell>
<TableCell>{item.sortNo}</TableCell>
<TableCell className="text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onAssignPermissions(item)}
disabled={disabled || actionLoading}
>
{actionLoading ? "处理中..." : "分配权限"}
</Button>
</TableCell>
</TableRow>
)
}
export default function DashboardRolesPage() {
const [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState(false)
const [savingPermissions, setSavingPermissions] = useState(false)
const [assignPermissionsLoading, setAssignPermissionsLoading] = useState(false)
const [assigningRole, setAssigningRole] = useState<AdminRole | null>(null)
const [assignPermissionOptions, setAssignPermissionOptions] = useState<
AdminPermission[]
>([])
const [assignPermissionIds, setAssignPermissionIds] = useState<number[]>([])
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<AdminRole>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
async function loadRoles() {
setLoading(true)
try {
setResult(await fetchRoles({ limit: 200 }))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载角色失败")
} finally {
setLoading(false)
}
}
async function openAssignPermissionsDrawer(role: AdminRole) {
setActionLoadingId(role.id)
setAssigningRole(role)
setAssignPermissionsLoading(true)
try {
const [permissionsResult, roleDetail] = await Promise.all([
fetchPermissions({ limit: 500 }),
fetchRoleDetail(role.id),
])
const permissionCodeSet = new Set(roleDetail.permissions || [])
setAssignPermissionOptions(permissionsResult.results)
setAssignPermissionIds(
permissionsResult.results
.filter((permission) => permissionCodeSet.has(permission.code))
.map((permission) => permission.id)
)
} catch (error) {
setAssigningRole(null)
toast.error(error instanceof Error ? error.message : "加载权限分配数据失败")
} finally {
setAssignPermissionsLoading(false)
setActionLoadingId(null)
}
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id || sorting) {
return
}
const previousResults = result.results
const oldIndex = previousResults.findIndex((item) => item.id === active.id)
const newIndex = previousResults.findIndex((item) => item.id === over.id)
if (oldIndex < 0 || newIndex < 0) {
return
}
const nextResults = arrayMove(previousResults, oldIndex, newIndex)
setResult((current) => ({
...current,
results: nextResults,
}))
setSorting(true)
try {
await updateRoleSort(nextResults.map((item) => item.id))
toast.success("角色排序已更新")
await loadRoles()
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}))
toast.error(error instanceof Error ? error.message : "更新角色排序失败")
} finally {
setSorting(false)
}
}
function handleAssignPermissionsOpenChange(open: boolean) {
if (savingPermissions) {
return
}
if (!open) {
setAssigningRole(null)
setAssignPermissionOptions([])
setAssignPermissionIds([])
}
}
async function handleAssignPermissions(permissionIds: number[]) {
if (!assigningRole || savingPermissions) {
return
}
setSavingPermissions(true)
try {
await assignRolePermissions(assigningRole.id, permissionIds)
toast.success(`已更新角色 ${assigningRole.name} 的权限`)
setAssigningRole(null)
setAssignPermissionOptions([])
setAssignPermissionIds([])
await loadRoles()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存权限分配失败")
} finally {
setSavingPermissions(false)
}
}
useEffect(() => {
void loadRoles()
}, [])
return (
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button onClick={() => void loadRoles()} disabled={loading || sorting}>
<RefreshCwIcon className={cn((loading || sorting) && "animate-spin")} />
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleDragEnd(event)}
>
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-14"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<SortableContext
items={result.results.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{result.results.map((item) => (
<SortableRoleRow
key={item.id}
item={item}
disabled={loading || sorting}
actionLoading={actionLoadingId === item.id}
onAssignPermissions={(current) =>
void openAssignPermissionsDrawer(current)
}
/>
))}
</SortableContext>
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="py-12 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{result.page.page} / {result.page.limit}
</span>
<span> {result.page.total} </span>
</div>
</div>
<AssignPermissionsDrawer
open={!!assigningRole}
saving={savingPermissions}
loading={assignPermissionsLoading}
item={assigningRole}
permissions={assignPermissionOptions}
selectedPermissionIds={assignPermissionIds}
onOpenChange={handleAssignPermissionsOpenChange}
onSubmit={handleAssignPermissions}
/>
</div>
)
}
@@ -0,0 +1,16 @@
import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
export default function DashboardSettingsPage() {
return (
<DashboardPlaceholder
eyebrow="Settings"
title="系统设置骨架"
description="系统设置页将管理认证参数、上传配置、基础信息与运行策略。"
nextSteps={[
"补充系统配置读取与保存接口。",
"按安全、存储、登录策略拆分设置区块。",
"增加敏感配置的脱敏显示和二次确认。",
]}
/>
)
}
@@ -0,0 +1,558 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useMemo, useState } from "react"
import { Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { LoaderCircleIcon, PlayIcon } from "lucide-react"
import { toast } from "sonner"
import { ProjectDialog } from "@/components/project-dialog"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
debugResumeSkillDefinition,
debugRunSkillDefinition,
fetchAIAgentsAll,
type AIAgent,
type SkillDebugResumePayload,
type SkillDebugRunPayload,
type SkillDebugRunResult,
} from "@/lib/api/admin"
type DebugDialogProps = {
open: boolean
skillCode: string
skillName: string
onOpenChange: (open: boolean) => void
}
const debugFormSchema = z.object({
aiAgentId: z.string().trim().min(1, "请选择 AI Agent"),
conversationId: z.string().trim(),
userMessage: z.string().trim().min(1, "请输入用户消息"),
})
type DebugForm = z.infer<typeof debugFormSchema>
const debugFormResolver = zodResolver(debugFormSchema as never) as Resolver<
z.input<typeof debugFormSchema>,
undefined,
z.output<typeof debugFormSchema>
>
const emptyForm: DebugForm = {
aiAgentId: "",
conversationId: "",
userMessage: "",
}
const quickResumeActions = [
{ label: "确认", value: "确认" },
{ label: "取消", value: "取消" },
]
function ResultBlock({
title,
value,
emptyText = "暂无数据",
}: {
title: string
value?: string
emptyText?: string
}) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">{title}</CardTitle>
</CardHeader>
<CardContent>
{value ? (
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-muted/50 p-3 text-xs leading-5">
{value}
</pre>
) : (
<div className="text-sm text-muted-foreground">{emptyText}</div>
)}
</CardContent>
</Card>
)
}
export function DebugDialog({
open,
skillCode,
skillName,
onOpenChange,
}: DebugDialogProps) {
if (!open) {
return null
}
return (
<DebugDialogBody
key={skillCode}
open={open}
skillCode={skillCode}
skillName={skillName}
onOpenChange={onOpenChange}
/>
)
}
function DebugDialogBody({
open,
skillCode,
skillName,
onOpenChange,
}: DebugDialogProps) {
const formId = `skill-debug-form-${skillCode}`
const [running, setRunning] = useState(false)
const [resuming, setResuming] = useState(false)
const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
const [result, setResult] = useState<SkillDebugRunResult | null>(null)
const [resumeResult, setResumeResult] = useState<SkillDebugRunResult | null>(null)
const [resumeMessage, setResumeMessage] = useState("")
const form = useForm<
z.input<typeof debugFormSchema>,
undefined,
z.output<typeof debugFormSchema>
>({
resolver: debugFormResolver,
defaultValues: emptyForm,
})
const {
handleSubmit,
reset,
register,
setValue,
watch,
formState: { errors },
} = form
const selectedAgentId = watch("aiAgentId")
useEffect(() => {
async function loadAIAgents() {
try {
const data = await fetchAIAgentsAll({ status: 1 })
setAiAgents(data)
} catch (error) {
console.error("Failed to load AI agents:", error)
}
}
void loadAIAgents()
}, [])
useEffect(() => {
if (!open) {
return
}
reset(emptyForm)
setResult(null)
setResumeResult(null)
setResumeMessage("")
}, [open, reset])
useEffect(() => {
if (!open || aiAgents.length === 0 || selectedAgentId) {
return
}
setValue("aiAgentId", String(aiAgents[0].id), { shouldValidate: true })
}, [aiAgents, open, selectedAgentId, setValue])
const aiAgentOptions = useMemo(
() =>
aiAgents.map((item) => ({
value: String(item.id),
label: item.name,
})),
[aiAgents],
)
const selectedAgent = useMemo(
() => aiAgents.find((item) => String(item.id) === selectedAgentId) ?? null,
[aiAgents, selectedAgentId],
)
async function onSubmit(values: DebugForm) {
const payload: SkillDebugRunPayload = {
aiAgentId: Number(values.aiAgentId),
skillCode,
userMessage: values.userMessage.trim(),
}
const conversationId = Number(values.conversationId)
if (conversationId > 0) {
payload.conversationId = conversationId
}
setRunning(true)
try {
const data = await debugRunSkillDefinition(payload)
setResult(data)
setResumeResult(null)
setResumeMessage("")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Skill 调试失败")
setResult(null)
} finally {
setRunning(false)
}
}
async function handleResumeDebug(messageText?: string) {
const nextMessage = (messageText ?? resumeMessage).trim()
if (!result?.checkPointId || !result.interrupted) {
return
}
if (!nextMessage) {
toast.error("请输入恢复消息")
return
}
const payload: SkillDebugResumePayload = {
aiAgentId: Number(selectedAgentId || result.aiAgentId),
checkPointId: result.checkPointId,
userMessage: nextMessage,
}
const conversationId = result.conversationId || Number(watch("conversationId"))
if (conversationId > 0) {
payload.conversationId = conversationId
}
setResuming(true)
try {
const data = await debugResumeSkillDefinition(payload)
setResumeResult(data)
setResumeMessage(nextMessage)
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复调试失败")
setResumeResult(null)
} finally {
setResuming(false)
}
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={`调试 Skill · ${skillName || skillCode}`}
description="强制指定当前 Skill,直接查看 route、tools、graph、HITL 和回复结果。"
size="xl"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
disabled={running}
onClick={() => onOpenChange(false)}
>
</Button>
<Button type="submit" form={formId} disabled={running}>
{running ? <LoaderCircleIcon className="animate-spin" /> : <PlayIcon />}
{running ? "调试中..." : "开始调试"}
</Button>
</>
}
>
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent>
<form id={formId} onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Field data-invalid={!!errors.aiAgentId}>
<FieldLabel>AI Agent</FieldLabel>
<FieldContent>
<OptionCombobox
value={selectedAgentId}
options={aiAgentOptions}
placeholder="选择 AI Agent"
searchPlaceholder="搜索 AI Agent"
emptyText="未找到 AI Agent"
onChange={(value) =>
setValue("aiAgentId", value, { shouldValidate: true })
}
/>
<FieldError errors={[errors.aiAgentId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.conversationId}>
<FieldLabel htmlFor="skill-debug-conversation-id">
Conversation ID
</FieldLabel>
<FieldContent>
<Input
id="skill-debug-conversation-id"
type="number"
min={0}
placeholder="可选,填已有会话 ID 以复用上下文"
aria-invalid={!!errors.conversationId}
{...register("conversationId")}
/>
<FieldError errors={[errors.conversationId]} />
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Field>
<FieldLabel>Skill</FieldLabel>
<FieldContent>
<Input value={skillCode} disabled />
</FieldContent>
</Field>
<Field>
<FieldLabel> Agent</FieldLabel>
<FieldContent>
<Input
value={selectedAgent?.name || "未选择"}
disabled
/>
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.userMessage}>
<FieldLabel htmlFor="skill-debug-user-message"></FieldLabel>
<FieldContent>
<Textarea
id="skill-debug-user-message"
rows={5}
placeholder="输入一段用户消息,调试当前 Skill 的路由、工具和回复。"
aria-invalid={!!errors.userMessage}
{...register("userMessage")}
/>
<FieldError errors={[errors.userMessage]} />
</FieldContent>
</Field>
</form>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{result?.skillCode || skillCode}</Badge>
{result?.graphToolCode ? (
<Badge variant="secondary">{result.graphToolCode}</Badge>
) : null}
{result?.interruptType ? (
<Badge variant="secondary">{result.interruptType}</Badge>
) : null}
{result?.interrupted ? (
<Badge></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Skill </div>
<div className="mt-1 font-medium">{result?.skillName || skillName}</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Plan Reason</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{result?.planReason || "暂无"}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Reply</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{result?.replyText || "暂无"}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Checkpoint</div>
<div className="mt-1 break-all">
{result?.checkPointId || "暂无"}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 whitespace-pre-wrap break-words">
{result?.errorMessage || "暂无"}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-2 flex flex-wrap gap-2">
{(result?.toolWhitelist ?? []).length > 0 ? (
result?.toolWhitelist.map((toolCode) => (
<Badge key={toolCode} variant="outline">
{toolCode}
</Badge>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-2 flex flex-wrap gap-2">
{(result?.exposedToolCodes ?? []).length > 0 ? (
result?.exposedToolCodes.map((toolCode) => (
<Badge key={toolCode} variant="outline">
{toolCode}
</Badge>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-2 flex flex-wrap gap-2">
{(result?.invokedToolCodes ?? []).length > 0 ? (
result?.invokedToolCodes.map((toolCode) => (
<Badge key={toolCode} variant="secondary">
{toolCode}
</Badge>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4">
<ResultBlock title="Skill Route Trace" value={result?.skillRouteTrace} />
<ResultBlock title="Tool Search Trace" value={result?.toolSearchTrace} />
<ResultBlock title="Graph Tool Trace" value={result?.graphToolTrace} />
<ResultBlock title="Trace Data" value={result?.traceData} />
</div>
{result?.interrupted && result.checkPointId ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg bg-muted/50 p-3 text-sm">
<div className="text-xs text-muted-foreground"> Checkpoint</div>
<div className="mt-1 break-all">{result.checkPointId}</div>
</div>
<Field>
<FieldLabel htmlFor="skill-debug-resume-message"></FieldLabel>
<FieldContent>
<Textarea
id="skill-debug-resume-message"
rows={3}
placeholder="输入确认、取消或其他恢复消息"
value={resumeMessage}
onChange={(event) => setResumeMessage(event.target.value)}
/>
</FieldContent>
</Field>
<div className="flex flex-wrap gap-2">
{quickResumeActions.map((item) => (
<Button
key={item.value}
type="button"
variant="outline"
disabled={resuming}
onClick={() => void handleResumeDebug(item.value)}
>
{item.label}
</Button>
))}
<Button
type="button"
disabled={resuming}
onClick={() => void handleResumeDebug()}
>
{resuming ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<PlayIcon />
)}
{resuming ? "恢复中..." : "恢复调试"}
</Button>
</div>
</CardContent>
</Card>
) : null}
{resumeResult ? (
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{resumeResult.skillCode || skillCode}</Badge>
{resumeResult.graphToolCode ? (
<Badge variant="secondary">{resumeResult.graphToolCode}</Badge>
) : null}
{resumeResult.interruptType ? (
<Badge variant="secondary">{resumeResult.interruptType}</Badge>
) : null}
{resumeResult.interrupted ? (
<Badge></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 whitespace-pre-wrap break-words">
{resumeMessage || "暂无"}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 whitespace-pre-wrap break-words">
{resumeResult.replyText || "暂无"}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground"> Plan Reason</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{resumeResult.planReason || "暂无"}
</div>
</div>
</CardContent>
</Card>
<ResultBlock title="恢复 Tool Search Trace" value={resumeResult.toolSearchTrace} />
<ResultBlock title="恢复 Graph Tool Trace" value={resumeResult.graphToolTrace} />
<ResultBlock title="恢复 Trace Data" value={resumeResult.traceData} />
</div>
) : null}
</div>
</ProjectDialog>
)
}
@@ -0,0 +1,402 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
fetchMCPCatalog,
fetchSkillDefinition,
type CreateSkillDefinitionPayload,
type MCPToolCatalogItem,
type SkillDefinition,
} from "@/lib/api/admin";
type SkillEditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateSkillDefinitionPayload) => Promise<void>;
};
const emptyForm: EditForm = {
code: "",
name: "",
description: "",
instruction: "",
examplesText: "",
remark: "",
};
const skillFormSchema = z.object({
code: z
.string()
.trim()
.min(1, "Skill 编码不能为空")
.regex(/^[a-zA-Z0-9_-]+$/, "Skill 编码仅支持字母、数字、下划线和中划线"),
name: z.string().trim().min(1, "Skill 名称不能为空"),
description: z.string().trim(),
instruction: z.string().trim().min(1, "技能说明不能为空"),
examplesText: z.string().trim(),
remark: z.string().trim(),
});
type EditForm = z.infer<typeof skillFormSchema>;
const editFormResolver = zodResolver(skillFormSchema as never) as Resolver<
z.input<typeof skillFormSchema>,
undefined,
z.output<typeof skillFormSchema>
>;
function buildForm(item: SkillDefinition | null): EditForm {
if (!item) {
return emptyForm;
}
return {
code: item.code,
name: item.name,
description: item.description ?? "",
instruction: item.instruction ?? "",
examplesText: (item.examples ?? []).join("\n"),
remark: item.remark ?? "",
};
}
function buildPayload(
form: EditForm,
toolWhitelist: string[],
): CreateSkillDefinitionPayload {
return {
code: form.code.trim(),
name: form.name.trim(),
description: form.description.trim(),
instruction: form.instruction.trim(),
examples: form.examplesText
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
toolWhitelist,
remark: form.remark.trim(),
};
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: SkillEditDialogProps) {
if (!open) {
return null;
}
return (
<SkillEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
saving={saving}
itemId={itemId}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type SkillEditDialogBodyProps = SkillEditDialogProps;
function SkillEditDialogBody({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: SkillEditDialogBodyProps) {
const formId = "skill-definition-edit-form";
const [loading, setLoading] = useState(false);
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
const [selectedToolWhitelist, setSelectedToolWhitelist] = useState<
string[]
>([]);
const [toolCodeToAdd, setToolCodeToAdd] = useState("");
const form = useForm<
z.input<typeof skillFormSchema>,
undefined,
z.output<typeof skillFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
const {
handleSubmit,
reset,
register,
formState: { errors },
} = form;
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm);
setSelectedToolWhitelist([]);
setToolCodeToAdd("");
return;
}
setLoading(true);
try {
const data = await fetchSkillDefinition(itemId);
reset(buildForm(data));
setSelectedToolWhitelist(data.toolWhitelist ?? []);
setToolCodeToAdd("");
} catch (error) {
console.error("Failed to load skill definition:", error);
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset]);
useEffect(() => {
async function loadToolCatalog() {
try {
const data = await fetchMCPCatalog();
setToolCatalog(data);
} catch (error) {
console.error("Failed to load MCP tool catalog:", error);
}
}
void loadToolCatalog();
}, []);
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, selectedToolWhitelist));
}
const toolOptions = useMemo(
() =>
toolCatalog.map((item) => ({
value: item.toolCode,
label: `${item.title || item.toolName} · ${item.toolCode}`,
})),
[toolCatalog],
);
const addableToolOptions = useMemo(
() =>
toolOptions.filter(
(option) => !selectedToolWhitelist.includes(option.value),
),
[selectedToolWhitelist, toolOptions],
);
const selectedToolOptions = useMemo(
() =>
selectedToolWhitelist
.map((toolCode) => toolOptions.find((option) => option.value === toolCode))
.filter(
(option): option is { value: string; label: string } => !!option,
),
[selectedToolWhitelist, toolOptions],
);
function handleAddToolWhitelist(toolCode: string) {
if (!toolCode || selectedToolWhitelist.includes(toolCode)) {
return;
}
setSelectedToolWhitelist((prev) => [...prev, toolCode]);
setToolCodeToAdd("");
}
function handleRemoveToolWhitelist(toolCode: string) {
setSelectedToolWhitelist((prev) =>
prev.filter((item) => item !== toolCode),
);
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑" : "新建"}
size="xl"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form
id={formId}
onSubmit={handleSubmit(onFormSubmit)}
className="space-y-4"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="skill-code"></FieldLabel>
<FieldContent>
<Input
id="skill-code"
placeholder="例如:refund_skill"
aria-invalid={!!errors.code}
{...register("code")}
/>
<FieldError errors={[errors.code]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="skill-name"></FieldLabel>
<FieldContent>
<Input
id="skill-name"
placeholder="例如:退款处理"
aria-invalid={!!errors.name}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="skill-description"></FieldLabel>
<FieldContent>
<Textarea
id="skill-description"
rows={3}
placeholder="描述这个 Skill 的用途、边界和适用场景"
aria-invalid={!!errors.description}
{...register("description")}
/>
<FieldError errors={[errors.description]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.instruction}>
<FieldLabel htmlFor="skill-instruction"></FieldLabel>
<FieldContent>
<Textarea
id="skill-instruction"
rows={12}
placeholder="请输入 Skill 文档内容,描述目标、步骤、工具使用规则和边界。"
aria-invalid={!!errors.instruction}
{...register("instruction")}
/>
<FieldError errors={[errors.instruction]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.examplesText}>
<FieldLabel htmlFor="skill-examples"></FieldLabel>
<FieldContent>
<Textarea
id="skill-examples"
rows={5}
placeholder={"每行一个典型用户问法,例如:\n我要申请退款\n帮我查下订单"}
aria-invalid={!!errors.examplesText}
{...register("examplesText")}
/>
<FieldError errors={[errors.examplesText]} />
</FieldContent>
</Field>
<Field>
<FieldLabel></FieldLabel>
<FieldContent className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1">
<OptionCombobox
value={toolCodeToAdd}
options={addableToolOptions}
placeholder="选择该 Skill 允许使用的工具"
searchPlaceholder="搜索 toolCode 或工具名"
emptyText="没有可添加的工具"
onChange={handleAddToolWhitelist}
/>
</div>
<Button
type="button"
variant="outline"
disabled={!toolCodeToAdd}
onClick={() => handleAddToolWhitelist(toolCodeToAdd)}
>
</Button>
</div>
<div className="flex flex-wrap gap-2">
{selectedToolOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
Skill Agent
</span>
) : (
selectedToolOptions.map((option) => (
<Button
key={option.value}
type="button"
variant="outline"
size="sm"
onClick={() => handleRemoveToolWhitelist(option.value)}
className="justify-start"
>
{option.label}
</Button>
))
)}
</div>
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="skill-remark"></FieldLabel>
<FieldContent>
<Textarea
id="skill-remark"
rows={3}
placeholder="记录内部备注或维护说明"
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}
@@ -0,0 +1,427 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import {
BrainCircuitIcon,
BugIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "sonner"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
createSkillDefinition,
deleteSkillDefinition,
fetchSkillDefinitions,
updateSkillDefinition,
updateSkillDefinitionStatus,
type CreateSkillDefinitionPayload,
type PageResult,
type SkillDefinition,
} from "@/lib/api/admin"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { formatDateTime } from "@/lib/utils"
import { EditDialog } from "./_components/edit"
import { DebugDialog } from "./_components/debug-dialog"
const statusFilterOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
]
type SkillRowProps = {
item: SkillDefinition
actionLoadingId: number | null
openEditDialog: (item: SkillDefinition) => void
openDebugDialog: (item: SkillDefinition) => void
handleToggleStatus: (item: SkillDefinition) => void
handleDelete: (item: SkillDefinition) => void
}
function SkillRow({
item,
actionLoadingId,
openEditDialog,
openDebugDialog,
handleToggleStatus,
handleDelete,
}: SkillRowProps) {
return (
<TableRow>
<TableCell>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<BrainCircuitIcon className="size-4" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{item.name}</div>
<Badge variant="outline">{item.code}</Badge>
<Badge variant="secondary"> {item.toolWhitelist.length}</Badge>
<Badge variant="secondary"> {item.examples.length}</Badge>
</div>
<div className="mt-2 space-y-2">
<div className="line-clamp-2 text-sm leading-6 text-muted-foreground">
{item.description || "暂无描述"}
</div>
</div>
{item.toolWhitelist.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{item.toolWhitelist.slice(0, 3).map((toolCode) => (
<Badge key={toolCode} variant="outline">
{toolCode}
</Badge>
))}
{item.toolWhitelist.length > 3 ? (
<Badge variant="outline">+{item.toolWhitelist.length - 3}</Badge>
) : null}
</div>
) : null}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Switch
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
/>
<Badge variant={item.status === Status.Ok ? "default" : "outline"}>
{getEnumLabel(StatusLabels, item.status as keyof typeof StatusLabels)}
</Badge>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{formatDateTime(item.updatedAt)}</div>
<div className="text-xs text-muted-foreground">
{item.updateUserName || "-"}
</div>
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openDebugDialog(item)}>
<BugIcon />
</Button>
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
disabled={actionLoadingId === item.id}
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
)
}
export default function DashboardSkillsPage() {
const [nameInput, setNameInput] = useState("")
const [codeInput, setCodeInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
const [name, setName] = useState("")
const [code, setCode] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [debugDialogOpen, setDebugDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<SkillDefinition | null>(null)
const [debuggingItem, setDebuggingItem] = useState<SkillDefinition | null>(null)
const [result, setResult] = useState<PageResult<SkillDefinition>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchSkillDefinitions({
name: name.trim() || undefined,
code: code.trim() || undefined,
status: statusFilter === "all" ? undefined : Number(statusFilter),
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Skills 失败")
} finally {
setLoading(false)
}
}, [name, code, statusFilter, page, limit])
useEffect(() => {
void loadData()
}, [loadData])
function applyFilters() {
setName(nameInput)
setCode(codeInput)
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)
}
function openCreateDialog() {
setEditingItem(null)
setDialogOpen(true)
}
function openEditDialog(item: SkillDefinition) {
setEditingItem(item)
setDialogOpen(true)
}
function openDebugDialog(item: SkillDefinition) {
setDebuggingItem(item)
setDebugDialogOpen(true)
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return
}
if (!open) {
setEditingItem(null)
}
setDialogOpen(open)
}
function handleDebugDialogOpenChange(open: boolean) {
if (!open) {
setDebuggingItem(null)
}
setDebugDialogOpen(open)
}
async function handleSubmit(payload: CreateSkillDefinitionPayload) {
if (saving) {
return
}
setSaving(true)
try {
if (editingItem) {
await updateSkillDefinition({
id: editingItem.id,
...payload,
})
toast.success(`已更新 Skill${editingItem.name}`)
} else {
await createSkillDefinition(payload)
toast.success(`已创建 Skill${payload.name}`)
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存 Skill 失败")
} finally {
setSaving(false)
}
}
async function handleToggleStatus(item: SkillDefinition) {
const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok
setActionLoadingId(item.id)
try {
await updateSkillDefinitionStatus(item.id, nextStatus)
toast.success(`${nextStatus === Status.Ok ? "启用" : "停用"}${item.name}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
} finally {
setActionLoadingId(null)
}
}
async function handleDelete(item: SkillDefinition) {
setActionLoadingId(item.id)
try {
await deleteSkillDefinition(item.id)
toast.success(`已删除 Skill${item.name}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除 Skill 失败")
} finally {
setActionLoadingId(null)
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-w-72">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选"
className="pl-9"
/>
</div>
<Input
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按编码筛选"
className="w-full xl:w-56"
/>
<div className="w-full xl:w-36">
<OptionCombobox
value={statusFilterInput}
options={statusFilterOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
onChange={setStatusFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Skill</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[168px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
Skill
</TableCell>
</TableRow>
) : null}
{result.results.map((item) => (
<SkillRow
key={item.id}
item={item}
actionLoadingId={actionLoadingId}
openEditDialog={openEditDialog}
openDebugDialog={openDebugDialog}
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
/>
))}
</TableBody>
</Table>
<div className="border-t px-4 py-3">
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
</div>
</div>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
<DebugDialog
open={debugDialogOpen}
skillCode={debuggingItem?.code ?? ""}
skillName={debuggingItem?.name ?? ""}
onOpenChange={handleDebugDialogOpenChange}
/>
</>
)
}
@@ -0,0 +1,292 @@
"use client";
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
type CreateTagPayload,
fetchTag,
fetchTagsAll,
type Tag,
type TagTree,
} from "@/lib/api/admin";
type TagFormDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateTagPayload) => Promise<void>;
};
const emptyForm: EditForm = {
parentId: "0",
name: "",
remark: "",
};
const tagFormSchema = z.object({
parentId: z.string(),
name: z.string().trim().min(1, "标签名称不能为空"),
remark: z.string(),
});
type EditForm = z.infer<typeof tagFormSchema>;
const editFormResolver = zodResolver(tagFormSchema as never) as Resolver<
z.input<typeof tagFormSchema>,
undefined,
z.output<typeof tagFormSchema>
>;
function buildForm(item: Tag | null): EditForm {
if (!item) {
return emptyForm;
}
return {
parentId: String(item.parentId),
name: item.name,
remark: item.remark,
};
}
function buildPayload(form: EditForm): CreateTagPayload {
return {
parentId: Number(form.parentId),
name: form.name.trim(),
remark: form.remark.trim(),
status: 0,
};
}
type TagTreeNode = TagTree & {
children: TagTreeNode[];
depth: number;
};
function withDepth(
nodes: TagTree[] | null | undefined,
depth = 0,
): TagTreeNode[] {
const safeNodes = Array.isArray(nodes) ? nodes : [];
return safeNodes.map((node) => ({
...node,
depth,
children: withDepth(node.children, depth + 1),
}));
}
function flattenTreeForSelect(
nodes: TagTreeNode[],
excludeId?: number,
): { id: number; name: string; depth: number }[] {
const result: { id: number; name: string; depth: number }[] = [];
function traverse(node: TagTreeNode) {
if (node.id !== excludeId) {
result.push({ id: node.id, name: node.name, depth: node.depth });
node.children.forEach(traverse);
}
}
nodes.forEach(traverse);
return result;
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: TagFormDialogProps) {
if (!open) {
return null;
}
return (
<TagFormDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
itemId={itemId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type TagFormDialogBodyProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateTagPayload) => Promise<void>;
};
function TagFormDialogBody({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: TagFormDialogBodyProps) {
const formId = "tag-edit-form";
const [loading, setLoading] = useState(false);
const [parentTags, setParentTags] = useState<
{ id: number; name: string; depth: number }[]
>([]);
const form = useForm<
z.input<typeof tagFormSchema>,
undefined,
z.output<typeof tagFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
const {
control,
handleSubmit,
reset,
register,
formState: { errors },
} = form;
useEffect(() => {
async function loadParentTags() {
try {
const data = await fetchTagsAll();
const tree = withDepth(data);
const flatList = flattenTreeForSelect(tree, itemId ?? undefined);
setParentTags(flatList);
} catch (error) {
console.error("Failed to load parent tags:", error);
}
}
void loadParentTags();
}, [itemId]);
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm);
return;
}
setLoading(true);
try {
const data = await fetchTag(itemId);
reset(buildForm(data));
} catch (error) {
console.error("Failed to load tag:", error);
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset]);
async function onFormSubmit(values: EditForm) {
const payload = buildPayload(values);
await onSubmit(payload);
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑标签" : "新建标签"}
size="md"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form
id={formId}
onSubmit={handleSubmit(onFormSubmit)}
className="space-y-4"
>
<Field data-invalid={!!errors.parentId}>
<FieldLabel htmlFor="tag-parent-id"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="parentId"
render={({ field }) => (
<select
id="tag-parent-id"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option value="0"></option>
{parentTags.map((tag) => (
<option key={tag.id} value={String(tag.id)}>
{" ".repeat(tag.depth)}
{tag.name}
</option>
))}
</select>
)}
/>
<FieldError errors={[errors.parentId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="tag-name"></FieldLabel>
<FieldContent>
<Input
id="tag-name"
placeholder="请输入标签名称"
aria-invalid={!!errors.name}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="tag-remark"></FieldLabel>
<FieldContent>
<Textarea
id="tag-remark"
placeholder="请输入备注(可选)"
rows={3}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}
+635
View File
@@ -0,0 +1,635 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { CSSProperties } from "react"
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
ChevronRightIcon,
GripVerticalIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "sonner"
import {
createTag,
deleteTag,
fetchTags,
fetchTagsAll,
updateTag,
updateTagSort,
updateTagStatus,
type CreateTagPayload,
type Tag,
type TagTree,
} from "@/lib/api/admin"
import { EditDialog } from "./_components/edit"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type TagNode = TagTree & {
children: TagNode[]
depth: number
}
function withDepth(nodes: TagTree[] | null | undefined, depth = 0): TagNode[] {
const safeNodes = Array.isArray(nodes) ? nodes : []
return safeNodes.map((node) => ({
...node,
depth,
children: withDepth(node.children, depth + 1),
}))
}
function collectParentIds(nodes: TagNode[]): Set<number> {
const ids = new Set<number>()
const walk = (items: TagNode[]) => {
items.forEach((item) => {
if (item.children.length > 0) {
ids.add(item.id)
walk(item.children)
}
})
}
walk(nodes)
return ids
}
function filterTree(nodes: TagNode[], keyword: string, status?: number): TagNode[] {
if (!keyword && status === undefined) {
return nodes
}
const result: TagNode[] = []
function matchesFilter(node: TagNode): boolean {
const nameMatch = !keyword || node.name.toLowerCase().includes(keyword.toLowerCase())
const statusMatch = status === undefined || node.status === status
return nameMatch && statusMatch
}
function hasMatchingDescendant(node: TagNode): boolean {
if (matchesFilter(node)) {
return true
}
return node.children.some(hasMatchingDescendant)
}
function filterNode(node: TagNode): TagNode | null {
if (!hasMatchingDescendant(node)) {
return null
}
const filteredChildren = node.children
.map(filterNode)
.filter((child): child is TagNode => child !== null)
if (matchesFilter(node)) {
return { ...node, children: filteredChildren }
}
if (filteredChildren.length > 0) {
return { ...node, children: filteredChildren }
}
return null
}
nodes.forEach((node) => {
const filtered = filterNode(node)
if (filtered) {
result.push(filtered)
}
})
return result
}
const listStatusOptions = [
{ value: "all", label: "全部状态" },
{ value: "0", label: "启用" },
{ value: "1", label: "禁用" },
] as const
type SortableRowProps = {
item: TagNode & { hasChildren: boolean }
disabled: boolean
expanded: boolean
onToggleExpand: () => void
onEdit: (item: TagNode) => void
onToggleStatus: (item: TagNode) => void
onDelete: (item: TagNode) => void
actionLoadingId: number | null
}
function SortableRow({
item,
disabled,
expanded,
onToggleExpand,
onEdit,
onToggleStatus,
onDelete,
actionLoadingId,
}: SortableRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<TableRow
ref={setNodeRef}
style={style}
className={cn(
isDragging && "relative z-10 bg-muted/60 shadow-sm",
!disabled && "cursor-move"
)}
>
<TableCell className="w-14">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon className="size-4 text-muted-foreground" />
</Button>
</TableCell>
<TableCell>
<div
className="flex items-center gap-2"
style={{ paddingLeft: item.depth * 24 }}
>
{item.hasChildren ? (
<button
type="button"
onClick={onToggleExpand}
className="flex size-6 items-center justify-center rounded hover:bg-muted"
>
<ChevronRightIcon
className={`size-4 transition-transform ${
expanded ? "rotate-90" : ""
}`}
/>
</button>
) : (
<span className="w-6" />
)}
<div className="flex size-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<TagIcon className="size-4" />
</div>
<span className="font-medium">{item.name}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Switch
checked={item.status === 0}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void onToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
/>
<Badge variant={item.status === 0 ? "default" : "outline"}>
{item.status === 0 ? "启用" : "禁用"}
</Badge>
</div>
</TableCell>
<TableCell>
<span className="line-clamp-2 text-sm text-muted-foreground">
{item.remark || "-"}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{item.createdAt}
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(item)}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
onClick={() => void onDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
)
}
export default function DashboardTagsPage() {
const [keywordInput, setKeywordInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
const [keyword, setKeyword] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState(false)
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<{ id: number; name: string } | null>(null)
const [allTags, setAllTags] = useState<Tag[]>([])
const [tree, setTree] = useState<TagNode[]>([])
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set())
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const loadData = useCallback(async () => {
setLoading(true)
try {
const [treeData, listData] = await Promise.all([
fetchTagsAll(),
fetchTags({ page: 1, limit: 10000 }),
])
const nextTree = withDepth(treeData)
setTree(nextTree)
setAllTags(Array.isArray(listData.results) ? listData.results : [])
setExpandedIds(collectParentIds(nextTree))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载标签失败")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadData()
}, [loadData])
function handleStatusFilterChange(value: string | null) {
setStatusFilterInput(value ?? "all")
}
function applyFilters() {
setKeyword(keywordInput)
setStatusFilter(statusFilterInput)
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
function toggleExpanded(id: number) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
function expandAll() {
setExpandedIds(collectParentIds(tree))
}
function collapseAll() {
setExpandedIds(new Set())
}
function openCreateDialog() {
setEditingItem(null)
setDialogOpen(true)
}
function openEditDialog(item: TagNode) {
setEditingItem(item)
setDialogOpen(true)
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return
}
if (!open) {
setEditingItem(null)
}
setDialogOpen(open)
}
async function handleSubmit(payload: CreateTagPayload) {
if (saving) {
return
}
setSaving(true)
try {
if (editingItem) {
await updateTag({
id: editingItem.id,
...payload,
})
toast.success(`已更新标签:${editingItem.name}`)
} else {
await createTag(payload)
toast.success(`已创建标签:${payload.name}`)
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存标签失败")
} finally {
setSaving(false)
}
}
async function handleToggleStatus(item: TagNode) {
setActionLoadingId(item.id)
try {
const nextStatus = item.status === 0 ? 1 : 0
await updateTagStatus(item.id, nextStatus)
toast.success(`${nextStatus === 0 ? "启用" : "禁用"}${item.name}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
} finally {
setActionLoadingId(null)
}
}
async function handleDelete(item: TagNode) {
setActionLoadingId(item.id)
try {
await deleteTag(item.id)
toast.success(`已删除标签:${item.name}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除标签失败")
} finally {
setActionLoadingId(null)
}
}
const filteredTree = useMemo(
() =>
filterTree(
tree,
keyword.trim(),
statusFilter === "all" ? undefined : Number(statusFilter)
),
[keyword, statusFilter, tree]
)
type FlatItem = TagNode & { hasChildren: boolean }
const [flatList, setFlatList] = useState<FlatItem[]>([])
useEffect(() => {
const items: FlatItem[] = []
function collectVisible(nodes: TagNode[]) {
nodes.forEach((node) => {
const hasChildren = node.children.length > 0
items.push({ ...node, hasChildren })
if (expandedIds.has(node.id)) {
collectVisible(node.children)
}
})
}
collectVisible(filteredTree)
setFlatList(items)
}, [filteredTree, expandedIds])
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id || sorting) {
return
}
const activeItem = flatList.find((item) => item.id === active.id)
const overItem = flatList.find((item) => item.id === over.id)
if (!activeItem || !overItem) {
return
}
if (activeItem.parentId !== overItem.parentId) {
toast.error("只能在同一父标签下拖拽排序")
return
}
const oldIndex = flatList.findIndex((item) => item.id === active.id)
const newIndex = flatList.findIndex((item) => item.id === over.id)
if (oldIndex < 0 || newIndex < 0) {
return
}
const previousList = [...flatList]
const nextList = arrayMove(flatList, oldIndex, newIndex)
setFlatList(nextList)
setSorting(true)
try {
const siblings = allTags.filter((t) => t.parentId === activeItem.parentId)
const siblingIds = siblings.map((t) => t.id)
const movedId = active.id as number
const targetId = over.id as number
const movedIndex = siblingIds.indexOf(movedId)
const targetIndex = siblingIds.indexOf(targetId)
if (movedIndex < 0 || targetIndex < 0) {
throw new Error("找不到标签")
}
const newSiblingIds = arrayMove(siblingIds, movedIndex, targetIndex)
await updateTagSort(newSiblingIds)
toast.success("标签排序已更新")
await loadData()
} catch (error) {
setFlatList(previousList)
toast.error(error instanceof Error ? error.message : "更新标签排序失败")
} finally {
setSorting(false)
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-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="按名称筛选"
className="pl-9"
/>
</div>
<select
value={statusFilterInput}
onChange={(e) => handleStatusFilterChange(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 xl:w-36"
>
{listStatusOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
<Button variant="outline" onClick={expandAll} disabled={loading}>
</Button>
<Button variant="outline" onClick={collapseAll} disabled={loading}>
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-14" />
<TableHead className="min-w-[260px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<SortableContext
items={flatList.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{flatList.map((item) => (
<SortableRow
key={item.id}
item={item}
disabled={loading || sorting}
expanded={expandedIds.has(item.id)}
onToggleExpand={() => toggleExpanded(item.id)}
onEdit={openEditDialog}
onToggleStatus={handleToggleStatus}
onDelete={handleDelete}
actionLoadingId={actionLoadingId}
/>
))}
</SortableContext>
{!loading && flatList.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</DndContext>
</div>
</div>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
)
}
@@ -0,0 +1,616 @@
"use client";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { zodResolver } from "@hookform/resolvers/zod";
import {
GripVerticalIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react";
import { useCallback, useEffect, useState, type CSSProperties } from "react";
import { Controller, useForm, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
import { useConfirm } from "@/components/confirm-provider";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
createTicketPriorityConfig,
deleteTicketPriorityConfig,
fetchTicketPriorityConfigs,
updateTicketPriorityConfig,
updateTicketPriorityConfigSort,
type CreateTicketPriorityConfigPayload,
type TicketPriorityConfig,
} from "@/lib/api/ticket-config";
import { getEnumOptions } from "@/lib/enums";
import { Status, StatusLabels } from "@/lib/generated/enums";
import { cn } from "@/lib/utils";
const listStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({ value: String(item.value), label: item.label })),
] as const;
const formSchema = z.object({
name: z.string().trim().min(1, "优先级名称不能为空"),
firstResponseMinutes: z
.string()
.trim()
.min(1, "首响时长不能为空")
.regex(/^\d+$/, "请输入正整数"),
resolutionMinutes: z
.string()
.trim()
.min(1, "解决时长不能为空")
.regex(/^\d+$/, "请输入正整数"),
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: "请选择状态",
}),
remark: z.string().trim(),
});
type EditForm = z.infer<typeof formSchema>;
const resolver = zodResolver(formSchema as never) as Resolver<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>;
const emptyForm: EditForm = {
name: "",
firstResponseMinutes: "30",
resolutionMinutes: "1440",
status: String(Status.Ok),
remark: "",
};
function buildForm(item: TicketPriorityConfig | null): EditForm {
if (!item) {
return emptyForm;
}
return {
name: item.name,
firstResponseMinutes: String(item.firstResponseMinutes),
resolutionMinutes: String(item.resolutionMinutes),
status: String(item.status) as EditForm["status"],
remark: item.remark || "",
};
}
function buildPayload(form: EditForm): CreateTicketPriorityConfigPayload {
return {
name: form.name.trim(),
firstResponseMinutes: Number(form.firstResponseMinutes),
resolutionMinutes: Number(form.resolutionMinutes),
status: Number(form.status),
remark: form.remark.trim(),
};
}
type SortablePriorityRowProps = {
item: TicketPriorityConfig;
disabled: boolean;
onEdit: (item: TicketPriorityConfig) => void;
onDelete: (item: TicketPriorityConfig) => void;
};
function SortablePriorityRow({
item,
disabled,
onEdit,
onDelete,
}: SortablePriorityRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<tr
ref={setNodeRef}
style={style}
className={cn(
"border-t",
isDragging && "relative z-10 bg-muted/60 shadow-sm",
)}
>
<td className="w-14 px-4 py-3">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon className="size-4 text-muted-foreground" />
</Button>
</td>
<td className="px-4 py-3">{item.name}</td>
<td className="px-4 py-3">{item.firstResponseMinutes} </td>
<td className="px-4 py-3">{item.resolutionMinutes} </td>
<td className="px-4 py-3">
<Badge variant={item.status === Status.Ok ? "default" : "secondary"}>
{item.status === Status.Ok ? "启用" : "停用"}
</Badge>
</td>
<td className="px-4 py-3 text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => onEdit(item)}>
<PencilIcon className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void onDelete(item)}
>
<Trash2Icon className="size-3.5" />
</Button>
</ButtonGroup>
</td>
</tr>
);
}
export default function TicketPrioritiesPage() {
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [sorting, setSorting] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<TicketPriorityConfig | null>(
null,
);
const [deleting, setDeleting] = useState(false);
const [items, setItems] = useState<TicketPriorityConfig[]>([]);
const confirm = useConfirm();
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 120, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchTicketPriorityConfigs({
name: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
});
setItems(Array.isArray(data) ? data : []);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载工单优先级失败",
);
} finally {
setLoading(false);
}
}, [keyword, statusFilter]);
useEffect(() => {
void loadData();
}, [loadData]);
function applyFilters() {
setKeyword(keywordInput);
setStatusFilter(statusFilterInput);
}
async function handleSubmit(payload: CreateTicketPriorityConfigPayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateTicketPriorityConfig({ id: editingItem.id, ...payload });
toast.success(`已更新工单优先级:${payload.name}`);
} else {
await createTicketPriorityConfig(payload);
toast.success(`已创建工单优先级:${payload.name}`);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存工单优先级失败",
);
} finally {
setSaving(false);
}
}
async function handleDelete(item: TicketPriorityConfig) {
if (deleting) {
return;
}
const confirmed = await confirm({
title: "确认删除优先级",
description: `删除后将无法恢复。确定要删除工单优先级“${item.name}”吗?`,
confirmText: "确认删除",
cancelText: "取消",
variant: "destructive",
});
if (!confirmed) {
return;
}
setDeleting(true);
try {
await deleteTicketPriorityConfig(item.id);
toast.success(`已删除工单优先级:${item.name}`);
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "删除工单优先级失败",
);
} finally {
setDeleting(false);
}
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id || sorting || loading) {
return;
}
const previousResults = items;
const oldIndex = previousResults.findIndex((item) => item.id === active.id);
const newIndex = previousResults.findIndex((item) => item.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const nextResults = arrayMove(previousResults, oldIndex, newIndex);
setItems(nextResults);
setSorting(true);
try {
await updateTicketPriorityConfigSort(nextResults.map((item) => item.id));
toast.success("工单优先级排序已更新");
await loadData();
} catch (error) {
setItems(previousResults);
toast.error(error instanceof Error ? error.message : "更新排序失败");
} finally {
setSorting(false);
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="relative min-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={(event) => {
if (event.key === "Enter") {
event.preventDefault();
applyFilters();
}
}}
placeholder="按优先级名称筛选"
className="pl-9"
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={statusFilterInput}
onChange={setStatusFilterInput}
placeholder="全部状态"
options={listStatusOptions.map((item) => ({
value: item.value,
label: item.label,
}))}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon className="size-4" />
</Button>
<Button
variant="outline"
onClick={() => void loadData()}
disabled={loading}
>
<RefreshCwIcon className="size-4" />
</Button>
<Button
onClick={() => {
setEditingItem(null);
setDialogOpen(true);
}}
>
<PlusIcon className="size-4" />
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-background">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<table className="w-full text-sm">
<thead className="bg-muted/35">
<tr>
<th className="w-14 px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td
colSpan={6}
className="h-32 text-center text-muted-foreground"
>
...
</td>
</tr>
) : items.length > 0 ? (
<SortableContext
items={items.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{items.map((item) => (
<SortablePriorityRow
key={item.id}
item={item}
disabled={sorting}
onEdit={(current) => {
setEditingItem(current);
setDialogOpen(true);
}}
onDelete={handleDelete}
/>
))}
</SortableContext>
) : (
<tr>
<td
colSpan={6}
className="h-32 text-center text-muted-foreground"
>
</td>
</tr>
)}
</tbody>
</table>
</DndContext>
</div>
</div>
<TicketPriorityEditDialog
open={dialogOpen}
saving={saving}
item={editingItem}
onOpenChange={(nextOpen) => {
setDialogOpen(nextOpen);
if (!nextOpen) {
setEditingItem(null);
}
}}
onSubmit={handleSubmit}
/>
</>
);
}
type TicketPriorityEditDialogProps = {
open: boolean;
saving: boolean;
item: TicketPriorityConfig | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateTicketPriorityConfigPayload) => Promise<void>;
};
function TicketPriorityEditDialog({
open,
saving,
item,
onOpenChange,
onSubmit,
}: TicketPriorityEditDialogProps) {
const formId = "ticket-priority-edit-form";
const form = useForm<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>({
resolver,
defaultValues: buildForm(item),
});
const {
register,
control,
handleSubmit,
reset,
formState: { errors },
} = form;
useEffect(() => {
reset(buildForm(item));
}, [item, reset]);
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={item ? "编辑工单优先级" : "新建工单优先级"}
description="优先级同时承载首响与解决时长配置。排序请在列表中拖动调整。"
size="md"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving}>
{saving ? "保存中..." : item ? "保存" : "创建"}
</Button>
</>
}
>
<form
id={formId}
className="space-y-4"
onSubmit={handleSubmit(async (values) =>
onSubmit(buildPayload(values)),
)}
>
<Field data-invalid={Boolean(errors.name)}>
<FieldLabel htmlFor="ticket-priority-name"></FieldLabel>
<FieldContent>
<Input
id="ticket-priority-name"
placeholder="请输入优先级名称"
{...register("name")}
/>
{errors.name ? <FieldError errors={[errors.name]} /> : null}
</FieldContent>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<Field data-invalid={Boolean(errors.firstResponseMinutes)}>
<FieldLabel htmlFor="ticket-priority-first-response">
</FieldLabel>
<FieldContent>
<Input
id="ticket-priority-first-response"
placeholder="分钟"
{...register("firstResponseMinutes")}
/>
{errors.firstResponseMinutes ? (
<FieldError errors={[errors.firstResponseMinutes]} />
) : null}
</FieldContent>
</Field>
<Field data-invalid={Boolean(errors.resolutionMinutes)}>
<FieldLabel htmlFor="ticket-priority-resolution">
</FieldLabel>
<FieldContent>
<Input
id="ticket-priority-resolution"
placeholder="分钟"
{...register("resolutionMinutes")}
/>
{errors.resolutionMinutes ? (
<FieldError errors={[errors.resolutionMinutes]} />
) : null}
</FieldContent>
</Field>
</div>
<Field data-invalid={Boolean(errors.status)}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder="请选择状态"
options={[
{ value: String(Status.Ok), label: "启用" },
{ value: String(Status.Disabled), label: "停用" },
]}
/>
)}
/>
{errors.status ? <FieldError errors={[errors.status]} /> : null}
</FieldContent>
</Field>
<Field data-invalid={Boolean(errors.remark)}>
<FieldLabel htmlFor="ticket-priority-remark"></FieldLabel>
<FieldContent>
<Textarea
id="ticket-priority-remark"
rows={4}
placeholder="可选"
{...register("remark")}
/>
{errors.remark ? <FieldError errors={[errors.remark]} /> : null}
</FieldContent>
</Field>
</form>
</ProjectDialog>
);
}
@@ -0,0 +1,477 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Controller, useForm, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
import { ListPagination } from "@/components/list-pagination";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
createTicketResolutionCode,
deleteTicketResolutionCode,
fetchTicketResolutionCodes,
updateTicketResolutionCode,
type CreateTicketResolutionCodePayload,
type PageResult,
type TicketResolutionCode,
} from "@/lib/api/ticket-config";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { Status, StatusLabels } from "@/lib/generated/enums";
const listStatusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({ value: String(item.value), label: item.label })),
] as const;
const formSchema = z.object({
name: z.string().trim().min(1, "解决码名称不能为空"),
code: z.string().trim().min(1, "解决码编码不能为空"),
sortNo: z
.string()
.trim()
.min(1, "排序不能为空")
.regex(/^\d+$/, "排序值必须是大于等于 0 的整数"),
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: "请选择状态",
}),
remark: z.string().trim(),
});
type EditForm = z.infer<typeof formSchema>;
const resolver = zodResolver(formSchema as never) as Resolver<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>;
const emptyForm: EditForm = {
name: "",
code: "",
sortNo: "0",
status: String(Status.Ok),
remark: "",
};
function buildForm(item: TicketResolutionCode | null): EditForm {
if (!item) {
return emptyForm;
}
return {
name: item.name,
code: item.code,
sortNo: String(item.sortNo),
status: String(item.status) as EditForm["status"],
remark: item.remark || "",
};
}
function buildPayload(form: EditForm): CreateTicketResolutionCodePayload {
return {
name: form.name.trim(),
code: form.code.trim(),
sortNo: Number(form.sortNo),
status: Number(form.status),
remark: form.remark.trim(),
};
}
export default function TicketResolutionCodesPage() {
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<TicketResolutionCode | null>(
null,
);
const [result, setResult] = useState<PageResult<TicketResolutionCode>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchTicketResolutionCodes({
name: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
page,
limit,
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载解决码失败");
} finally {
setLoading(false);
}
}, [keyword, statusFilter, page, limit]);
useEffect(() => {
void loadData();
}, [loadData]);
function applyFilters() {
setKeyword(keywordInput);
setStatusFilter(statusFilterInput);
setPage(1);
}
async function handleSubmit(payload: CreateTicketResolutionCodePayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateTicketResolutionCode({ id: editingItem.id, ...payload });
toast.success(`已更新解决码:${payload.name}`);
} else {
await createTicketResolutionCode(payload);
toast.success(`已创建解决码:${payload.name}`);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存解决码失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: TicketResolutionCode) {
try {
await deleteTicketResolutionCode(item.id);
toast.success(`已删除解决码:${item.name}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除解决码失败");
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="relative min-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={(event) => {
if (event.key === "Enter") {
event.preventDefault();
applyFilters();
}
}}
placeholder="按解决码名称筛选"
className="pl-9"
/>
</div>
<div className="w-full xl:w-40">
<OptionCombobox
value={statusFilterInput}
onChange={setStatusFilterInput}
placeholder="全部状态"
options={listStatusOptions.map((item) => ({
value: item.value,
label: item.label,
}))}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon className="size-4" />
</Button>
<Button
variant="outline"
onClick={() => void loadData()}
disabled={loading}
>
<RefreshCwIcon className="size-4" />
</Button>
<Button
onClick={() => {
setEditingItem(null);
setDialogOpen(true);
}}
>
<PlusIcon className="size-4" />
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-background">
<table className="w-full text-sm">
<thead className="bg-muted/35">
<tr>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td
colSpan={5}
className="h-32 text-center text-muted-foreground"
>
...
</td>
</tr>
) : result.results.length > 0 ? (
result.results.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-4 py-3">{item.name}</td>
<td className="px-4 py-3 font-mono text-xs">{item.code}</td>
<td className="px-4 py-3">
<Badge
variant={
item.status === Status.Ok ? "default" : "secondary"
}
>
{getEnumLabel(StatusLabels, item.status as Status)}
</Badge>
</td>
<td className="px-4 py-3">{item.sortNo}</td>
<td className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="sm" />}
>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditingItem(item);
setDialogOpen(true);
}}
>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="h-32 text-center text-muted-foreground"
>
</td>
</tr>
)}
</tbody>
</table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading}
onPageChange={setPage}
onLimitChange={(value) => {
setLimit(value);
setPage(1);
}}
/>
</div>
<TicketResolutionCodeEditDialog
open={dialogOpen}
saving={saving}
item={editingItem}
onOpenChange={(open) => {
if (!saving) {
setDialogOpen(open);
if (!open) {
setEditingItem(null);
}
}
}}
onSubmit={handleSubmit}
/>
</>
);
}
type TicketResolutionCodeEditDialogProps = {
open: boolean;
saving: boolean;
item: TicketResolutionCode | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateTicketResolutionCodePayload) => Promise<void>;
};
function TicketResolutionCodeEditDialog({
open,
saving,
item,
onOpenChange,
onSubmit,
}: TicketResolutionCodeEditDialogProps) {
const formId = "ticket-resolution-code-edit-form";
const form = useForm<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>({
resolver,
defaultValues: emptyForm,
});
const {
control,
register,
handleSubmit,
reset,
formState: { errors },
} = form;
useEffect(() => {
reset(buildForm(item));
}, [item, reset]);
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={item ? "编辑解决码" : "新建解决码"}
size="md"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving}>
{saving ? "保存中..." : item ? "保存" : "创建"}
</Button>
</>
}
>
<form
id={formId}
onSubmit={handleSubmit(async (values) =>
onSubmit(buildPayload(values)),
)}
className="space-y-4"
>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ticket-resolution-code-name">
</FieldLabel>
<FieldContent>
<Input
id="ticket-resolution-code-name"
placeholder="请输入解决码名称"
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="ticket-resolution-code-code">
</FieldLabel>
<FieldContent>
<Input
id="ticket-resolution-code-code"
placeholder="请输入解决码编码"
{...register("code")}
/>
<FieldError errors={[errors.code]} />
</FieldContent>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<Field data-invalid={!!errors.sortNo}>
<FieldLabel htmlFor="ticket-resolution-code-sort"></FieldLabel>
<FieldContent>
<Input id="ticket-resolution-code-sort" {...register("sortNo")} />
<FieldError errors={[errors.sortNo]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.status}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder="请选择状态"
options={listStatusOptions
.filter((item) => item.value !== "all")
.map((item) => ({
value: item.value,
label: item.label,
}))}
/>
)}
/>
<FieldError errors={[errors.status]} />
</FieldContent>
</Field>
</div>
<Field>
<FieldLabel htmlFor="ticket-resolution-code-remark"></FieldLabel>
<FieldContent>
<Textarea
id="ticket-resolution-code-remark"
rows={4}
{...register("remark")}
/>
</FieldContent>
</Field>
</form>
</ProjectDialog>
);
}
@@ -0,0 +1,326 @@
"use client"
import Link from "next/link"
import { AlertTriangleIcon, CircleDashedIcon, RefreshCcwIcon, TimerResetIcon, WrenchIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
fetchAgentTeamsAll,
type AdminAgentTeam,
} from "@/lib/api/admin"
import {
fetchTicketRiskOverview,
fetchTicketRiskList,
type TicketItem,
type TicketRiskOverview,
} from "@/lib/api/ticket"
import { formatDateTime } from "@/lib/utils"
import { TicketPriorityBadge } from "../tickets/_components/ticket-priority-badge"
import { TicketSLABadge } from "../tickets/_components/ticket-sla-badge"
import { TicketStatusBadge } from "../tickets/_components/ticket-status-badge"
type RiskTableProps = {
title: string
description: string
items: TicketItem[]
emptyText: string
}
function RiskTable({ title, description, items, emptyText }: RiskTableProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>SLA</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length > 0 ? (
items.map((item) => (
<TableRow key={item.id}>
<TableCell className="min-w-64">
<div className="space-y-1">
<div className="font-medium">{item.title}</div>
<div className="text-xs text-muted-foreground">{item.ticketNo}</div>
</div>
</TableCell>
<TableCell>
{item.tags && item.tags.length > 0
? item.tags.map((tag) => tag.name).join(" / ")
: "未打标签"}
</TableCell>
<TableCell>
<TicketPriorityBadge priority={item.priority} priorityName={item.priorityName} />
</TableCell>
<TableCell>
<TicketStatusBadge status={item.status} />
</TableCell>
<TableCell>
<TicketSLABadge ticket={item} />
</TableCell>
<TableCell>{item.currentAssigneeName || "未指派"}</TableCell>
<TableCell>{item.updatedAt ? formatDateTime(item.updatedAt) : "—"}</TableCell>
<TableCell className="text-right">
<Link href={`/tickets/detail?id=${item.id}`} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm">
</Button>
</Link>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
{emptyText}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
export default function TicketRiskPage() {
const [loading, setLoading] = useState(true)
const [overview, setOverview] = useState<TicketRiskOverview | null>(null)
const [overdueTickets, setOverdueTickets] = useState<TicketItem[]>([])
const [highRiskTickets, setHighRiskTickets] = useState<TicketItem[]>([])
const [unassignedTickets, setUnassignedTickets] = useState<TicketItem[]>([])
const [pendingInternalTickets, setPendingInternalTickets] = useState<TicketItem[]>([])
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [teamFilter, setTeamFilter] = useState("all")
const [riskWindow, setRiskWindow] = useState("240")
useEffect(() => {
void (async () => {
try {
const data = await fetchAgentTeamsAll()
setTeams(Array.isArray(data) ? data : [])
} catch {
setTeams([])
}
})()
}, [])
const loadData = useCallback(async () => {
setLoading(true)
try {
const currentTeamId = teamFilter === "all" ? undefined : Number(teamFilter)
const riskMinutes = Number(riskWindow)
const [overviewData, overdueData, highRiskData, unassignedData, pendingInternalData] =
await Promise.all([
fetchTicketRiskOverview({ currentTeamId, riskWindowMins: riskMinutes }),
fetchTicketRiskList({ riskType: "overdue", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
fetchTicketRiskList({ riskType: "high_risk", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
fetchTicketRiskList({ riskType: "unassigned", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
fetchTicketRiskList({ riskType: "pending_internal", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
])
setOverview(overviewData)
setOverdueTickets(Array.isArray(overdueData.results) ? overdueData.results : [])
setHighRiskTickets(Array.isArray(highRiskData.results) ? highRiskData.results : [])
setUnassignedTickets(Array.isArray(unassignedData.results) ? unassignedData.results : [])
setPendingInternalTickets(
Array.isArray(pendingInternalData.results) ? pendingInternalData.results : [],
)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 SLA 风险页失败")
} finally {
setLoading(false)
}
}, [riskWindow, teamFilter])
useEffect(() => {
void loadData()
}, [loadData])
const cards = useMemo(
() => [
{
title: "已超时",
description: "解决 SLA 已经 breach 的工单",
value: overview?.overdue ?? 0,
icon: AlertTriangleIcon,
tone: "text-red-700 bg-red-500/10",
},
{
title: `${Number(riskWindow) / 60} 小时内到期`,
description: "建议组长优先盯防的风险队列",
value: overview?.highRisk ?? 0,
icon: TimerResetIcon,
tone: "text-orange-700 bg-orange-500/10",
},
{
title: "待分配",
description: "目前还没有明确负责人的工单",
value: overview?.unassigned ?? 0,
icon: CircleDashedIcon,
tone: "text-amber-700 bg-amber-500/10",
},
{
title: "待内部处理",
description: "等待内部团队协作处理的工单",
value: overview?.pendingInternal ?? 0,
icon: WrenchIcon,
tone: "text-blue-700 bg-blue-500/10",
},
],
[overview, riskWindow],
)
return (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4 md:p-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-xl font-semibold">SLA </h1>
<p className="mt-1 text-sm text-muted-foreground">
使
</p>
</div>
<div className="flex gap-2">
<div className="w-44">
<OptionCombobox
value={teamFilter}
onChange={setTeamFilter}
placeholder="全部团队"
options={[
{ value: "all", label: "全部团队" },
...teams.map((team) => ({ value: String(team.id), label: team.name })),
]}
/>
</div>
<div className="w-44">
<OptionCombobox
value={riskWindow}
onChange={setRiskWindow}
placeholder="风险时间窗"
options={[
{ value: "60", label: "1 小时内" },
{ value: "240", label: "4 小时内" },
{ value: "1440", label: "24 小时内" },
]}
/>
</div>
<Link href="/tickets">
<Button variant="outline"></Button>
</Link>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCcwIcon className="size-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{cards.map((item) => {
const Icon = item.icon
return (
<Card key={item.title}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-sm font-medium">{item.title}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</div>
<div className={`rounded-full p-2 ${item.tone}`}>
<Icon className="size-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold tracking-tight">
{loading ? "..." : item.value.toLocaleString()}
</div>
</CardContent>
</Card>
)
})}
</div>
<RiskTable
title="已超时工单"
description="需要立即处理或升级的高风险工单"
items={overdueTickets}
emptyText="当前没有已超时工单"
/>
<RiskTable
title="4 小时内到期"
description="建议优先处理,避免进入超时队列"
items={highRiskTickets}
emptyText="当前没有临近超时工单"
/>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> SLA </CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{(overview?.reasons?.length ?? 0) > 0 ? (
overview?.reasons?.map((item) => (
<div key={item.code} className="rounded-lg border bg-muted/20 p-4">
<div className="text-sm font-medium">{item.title}</div>
<div className="mt-2 text-2xl font-semibold">{item.count.toLocaleString()}</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">{item.description}</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground"></div>
)}
</div>
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-2">
<RiskTable
title="待分配工单"
description="进入队列但尚未明确负责人的工单"
items={unassignedTickets}
emptyText="当前没有待分配工单"
/>
<RiskTable
title="待内部处理"
description="需要内部团队介入,容易长期滞留的工单"
items={pendingInternalTickets}
emptyText="当前没有待内部处理工单"
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,69 @@
"use client"
import { toast } from "sonner"
import { createTicketFromConversation } from "@/lib/api/ticket"
import { EditDialog } from "./edit"
type ConversationSeed = {
id: number
subject: string
customerId?: number
lastMessageSummary?: string
currentAssigneeId?: number
}
type CreateTicketFromConversationDialogProps = {
open: boolean
conversation: ConversationSeed | null
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
export function CreateTicketFromConversationDialog({
open,
conversation,
onOpenChange,
onSuccess,
}: CreateTicketFromConversationDialogProps) {
const initialValues = conversation
? {
title: conversation.subject || "",
description: conversation.lastMessageSummary || "",
priority: 2,
severity: 1,
currentAssigneeId: conversation.currentAssigneeId || undefined,
}
: undefined
return (
<EditDialog
open={open}
saving={false}
itemId={null}
onOpenChange={onOpenChange}
fixedConversationId={conversation?.id}
fixedCustomerId={conversation?.customerId}
initialValues={initialValues}
titleOverride="会话转工单"
descriptionOverride="从当前会话上下文创建正式工单"
onSubmit={async (payload) => {
if (!conversation?.id) {
throw new Error("会话不存在")
}
await createTicketFromConversation({
conversationId: conversation.id,
title: payload.title,
description: payload.description,
priority: payload.priority,
severity: payload.severity,
currentTeamId: payload.currentTeamId,
currentAssigneeId: payload.currentAssigneeId,
syncToConversation: true,
})
toast.success("工单创建成功")
onSuccess?.()
}}
/>
)
}
@@ -0,0 +1,546 @@
"use client"
import { CheckIcon, TagIcon } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import type { Resolver } from "react-hook-form"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { OptionCombobox } from "@/components/option-combobox"
import { ProjectDialog } from "@/components/project-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Field,
FieldContent,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Textarea } from "@/components/ui/textarea"
import {
fetchAgentProfilesAll,
fetchAgentTeamsAll,
fetchTagsAll,
type AdminAgentProfile,
type AdminAgentTeam,
type TagTree,
} from "@/lib/api/admin"
import {
fetchTicketPriorityConfigsAll,
type TicketPriorityConfig,
} from "@/lib/api/ticket-config"
import {
fetchTicketDetail,
type CreateTicketPayload,
type TicketItem,
type UpdateTicketPayload,
} from "@/lib/api/ticket"
type EditDialogProps = {
open: boolean
saving: boolean
itemId: number | null
initialValues?: Partial<CreateTicketPayload>
fixedConversationId?: number
fixedCustomerId?: number
titleOverride?: string
descriptionOverride?: string
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
}
const ticketFormSchema = z.object({
title: z.string().trim().min(1, "标题不能为空"),
description: z.string().trim(),
tagIds: z.array(z.string().trim()).default([]),
priority: z.string().trim().min(1, "请选择优先级"),
severity: z.enum(["1", "2", "3"], { message: "请选择严重度" }),
currentTeamId: z.string().trim(),
currentAssigneeId: z.string().trim(),
dueAt: z.string().trim(),
})
type EditForm = z.infer<typeof ticketFormSchema>
const editFormResolver = zodResolver(ticketFormSchema as never) as Resolver<
z.input<typeof ticketFormSchema>,
undefined,
z.output<typeof ticketFormSchema>
>
const emptyForm: EditForm = {
title: "",
description: "",
tagIds: [],
priority: "",
severity: "1",
currentTeamId: "",
currentAssigneeId: "",
dueAt: "",
}
function buildForm(item: TicketItem | null): EditForm {
if (!item) {
return emptyForm
}
return {
title: item.title ?? "",
description: item.description ?? "",
tagIds: (item.tags ?? []).map((tag) => String(tag.id)),
priority: item.priority ? String(item.priority) : "",
severity: String(item.severity || 1) as EditForm["severity"],
currentTeamId: item.currentTeamId ? String(item.currentTeamId) : "",
currentAssigneeId: item.currentAssigneeId ? String(item.currentAssigneeId) : "",
dueAt: item.dueAt ? item.dueAt.replace(" ", "T").slice(0, 16) : "",
}
}
function buildInitialForm(initialValues?: Partial<CreateTicketPayload>): EditForm {
return {
title: initialValues?.title?.trim() ?? "",
description: initialValues?.description?.trim() ?? "",
tagIds: (initialValues?.tagIds ?? []).map((tagId) => String(tagId)),
priority: initialValues?.priority ? String(initialValues.priority) : "",
severity: String(initialValues?.severity ?? 1) as EditForm["severity"],
currentTeamId: initialValues?.currentTeamId ? String(initialValues.currentTeamId) : "",
currentAssigneeId: initialValues?.currentAssigneeId
? String(initialValues.currentAssigneeId)
: "",
dueAt: initialValues?.dueAt ? initialValues.dueAt.replace(" ", "T").slice(0, 16) : "",
}
}
function buildPayload(form: EditForm): CreateTicketPayload {
return {
title: form.title.trim(),
description: form.description.trim(),
tagIds: form.tagIds.length > 0 ? form.tagIds.map((tagId) => Number(tagId)) : undefined,
priority: Number(form.priority),
severity: Number(form.severity),
currentTeamId: form.currentTeamId ? Number(form.currentTeamId) : undefined,
currentAssigneeId: form.currentAssigneeId ? Number(form.currentAssigneeId) : undefined,
dueAt: form.dueAt ? `${form.dueAt.replace("T", " ")}:00` : undefined,
}
}
type FlatTagNode = TagTree & {
depth: number
path: string
}
function flattenTagTree(nodes: TagTree[], depth = 0, parentPath = ""): FlatTagNode[] {
const result: FlatTagNode[] = []
nodes.forEach((item) => {
const path = parentPath ? `${parentPath} / ${item.name}` : item.name
result.push({ ...item, depth, path })
if (item.children.length > 0) {
result.push(...flattenTagTree(item.children, depth + 1, path))
}
})
return result
}
type TicketTagSelectorProps = {
value?: string[]
onChange: (value: string[]) => void
availableTags: TagTree[]
}
function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelectorProps) {
const selectedValues = value ?? []
const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues])
const selectedTags = useMemo(
() => flatTags.filter((tag) => selectedTagIDs.has(String(tag.id))),
[flatTags, selectedTagIDs],
)
function handleToggle(tagID: string) {
if (selectedTagIDs.has(tagID)) {
onChange(selectedValues.filter((item) => item !== tagID))
return
}
onChange(selectedValues.concat(tagID))
}
return (
<div className="space-y-2">
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="outline" className="w-full justify-start" />
}
>
<TagIcon className="size-4" />
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"}
</PopoverTrigger>
<PopoverContent align="start" className="w-[320px] p-0">
<Command>
<CommandInput placeholder="搜索标签" />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup heading="标签">
{flatTags.map((tag) => {
const checked = selectedTagIDs.has(String(tag.id))
return (
<CommandItem
key={tag.id}
value={`${tag.id} ${tag.path} ${tag.remark}`}
onSelect={() => handleToggle(String(tag.id))}
>
<CheckIcon className={`mr-2 size-4 ${checked ? "opacity-100" : "opacity-0"}`} />
<span className="truncate" style={{ paddingLeft: `${tag.depth * 12}px` }}>
{tag.name}
</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedTags.map((tag) => (
<Badge key={tag.id} variant="outline">
{tag.path}
</Badge>
))}
</div>
) : null}
</div>
)
}
export function EditDialog({
open,
saving,
itemId,
initialValues,
fixedConversationId,
fixedCustomerId,
titleOverride,
descriptionOverride,
onOpenChange,
onSubmit,
}: EditDialogProps) {
if (!open) {
return null
}
return (
<TicketEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
saving={saving}
itemId={itemId}
initialValues={initialValues}
fixedConversationId={fixedConversationId}
fixedCustomerId={fixedCustomerId}
titleOverride={titleOverride}
descriptionOverride={descriptionOverride}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)
}
type TicketEditDialogBodyProps = EditDialogProps
function TicketEditDialogBody({
open,
saving,
itemId,
initialValues,
fixedConversationId,
fixedCustomerId,
titleOverride,
descriptionOverride,
onOpenChange,
onSubmit,
}: TicketEditDialogBodyProps) {
const formId = "ticket-edit-form"
const [loading, setLoading] = useState(false)
const [tags, setTags] = useState<TagTree[]>([])
const [priorities, setPriorities] = useState<TicketPriorityConfig[]>([])
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const form = useForm<
z.input<typeof ticketFormSchema>,
undefined,
z.output<typeof ticketFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
const {
register,
control,
handleSubmit,
reset,
formState: { errors },
} = form
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(buildInitialForm(initialValues))
return
}
setLoading(true)
try {
const data = await fetchTicketDetail(itemId)
reset(buildForm(data.ticket))
} finally {
setLoading(false)
}
}
void loadDetail()
}, [initialValues, itemId, reset])
useEffect(() => {
if (!open) {
return
}
void (async () => {
const [tagData, priorityData, teamData, agentData] = await Promise.all([
fetchTagsAll(),
fetchTicketPriorityConfigsAll(),
fetchAgentTeamsAll(),
fetchAgentProfilesAll(),
])
setTags(Array.isArray(tagData) ? tagData : [])
setPriorities(Array.isArray(priorityData) ? priorityData : [])
setTeams(Array.isArray(teamData) ? teamData : [])
setAgents(Array.isArray(agentData) ? agentData : [])
})()
}, [open])
const priorityOptions = priorities.map((priority) => ({
value: String(priority.id),
label: priority.name,
}))
const teamOptions = [{ value: "", label: "不指定团队" }].concat(
teams.map((team) => ({
value: String(team.id),
label: team.name,
})),
)
const agentOptions = [{ value: "", label: "不指定处理人" }].concat(
agents.map((agent) => ({
value: String(agent.userId),
label:
agent.displayName ||
agent.nickname ||
agent.username ||
`客服#${agent.userId}`,
})),
)
async function onFormSubmit(values: EditForm) {
const payload = buildPayload(values)
if (itemId) {
await onSubmit({
ticketId: itemId,
...payload,
})
return
}
await onSubmit({
...payload,
source: fixedConversationId ? "conversation" : "manual",
conversationId: fixedConversationId,
customerId: fixedCustomerId,
})
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={titleOverride || (itemId ? "编辑工单" : "新建工单")}
description={descriptionOverride || "填写工单基础信息"}
size="lg"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<FieldGroup>
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="ticket-title"></FieldLabel>
<FieldContent>
<Input
id="ticket-title"
placeholder="请输入工单标题"
aria-invalid={!!errors.title}
{...register("title")}
/>
<FieldError errors={[errors.title]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="ticket-description"></FieldLabel>
<FieldContent>
<Textarea
id="ticket-description"
rows={5}
placeholder="请输入问题描述"
aria-invalid={!!errors.description}
{...register("description")}
/>
<FieldError errors={[errors.description]} />
</FieldContent>
</Field>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel></FieldLabel>
</div>
<FieldContent>
<Controller
control={control}
name="tagIds"
render={({ field }) => (
<TicketTagSelector
value={field.value}
onChange={field.onChange}
availableTags={tags}
/>
)}
/>
</FieldContent>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<Field data-invalid={!!errors.priority}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="priority"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder="请选择优先级"
options={priorityOptions}
/>
)}
/>
<FieldError errors={[errors.priority]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.severity}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="severity"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder="请选择严重度"
options={[
{ value: "1", label: "轻微" },
{ value: "2", label: "严重" },
{ value: "3", label: "致命" },
]}
/>
)}
/>
<FieldError errors={[errors.severity]} />
</FieldContent>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="currentTeamId"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder="请选择团队"
options={teamOptions}
/>
)}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="currentAssigneeId"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder="请选择处理人"
options={agentOptions}
/>
)}
/>
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.dueAt}>
<FieldLabel htmlFor="ticket-due-at"></FieldLabel>
<FieldContent>
<Input
id="ticket-due-at"
type="datetime-local"
aria-invalid={!!errors.dueAt}
{...register("dueAt")}
/>
<FieldError errors={[errors.dueAt]} />
</FieldContent>
</Field>
</FieldGroup>
</form>
)}
</ProjectDialog>
)
}
@@ -0,0 +1,243 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import {
fetchAgentProfilesAll,
fetchAgentTeamsAll,
type AdminAgentProfile,
type AdminAgentTeam,
} from "@/lib/api/admin"
import { assignTicket, batchAssignTickets } from "@/lib/api/ticket"
const schema = z.object({
toUserId: z.string().trim().min(1, "请选择处理人"),
toTeamId: z.string().trim(),
reason: z.string().trim(),
})
type FormValues = z.infer<typeof schema>
const resolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
const emptyForm: FormValues = {
toUserId: "",
toTeamId: "",
reason: "",
}
type TicketAssignDialogProps = {
open: boolean
ticketId: number | null
ticketIds?: number[]
currentTeamId?: number
currentAssigneeId?: number
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
export function TicketAssignDialog({
open,
ticketId,
ticketIds,
currentTeamId,
currentAssigneeId,
onOpenChange,
onSuccess,
}: TicketAssignDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<TicketAssignDialogBody
key={ticketId ?? "ticket-assign"}
ticketId={ticketId}
ticketIds={ticketIds}
currentTeamId={currentTeamId}
currentAssigneeId={currentAssigneeId}
onOpenChange={onOpenChange}
onSuccess={onSuccess}
/>
) : null}
</Dialog>
)
}
function TicketAssignDialogBody({
ticketId,
ticketIds,
currentTeamId,
currentAssigneeId,
onOpenChange,
onSuccess,
}: Omit<TicketAssignDialogProps, "open">) {
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
resolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
register,
reset,
formState: { errors },
} = form
useEffect(() => {
reset({
toUserId: currentAssigneeId ? String(currentAssigneeId) : "",
toTeamId: currentTeamId ? String(currentTeamId) : "",
reason: "",
})
}, [currentAssigneeId, currentTeamId, reset, ticketId])
useEffect(() => {
setLoading(true)
Promise.all([fetchAgentTeamsAll(), fetchAgentProfilesAll()])
.then(([teamData, agentData]) => {
setTeams(Array.isArray(teamData) ? teamData : [])
setAgents(Array.isArray(agentData) ? agentData : [])
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "加载处理人失败")
})
.finally(() => setLoading(false))
}, [])
async function onFormSubmit(values: FormValues) {
const validTicketIds = (ticketIds ?? []).filter((item) => item > 0)
if (!ticketId && validTicketIds.length === 0) {
toast.error("请选择工单")
return
}
setSaving(true)
try {
if (validTicketIds.length > 0) {
await batchAssignTickets({
ticketIds: validTicketIds,
toUserId: Number(values.toUserId),
toTeamId: values.toTeamId ? Number(values.toTeamId) : undefined,
reason: values.reason.trim() || undefined,
})
toast.success(`已批量指派 ${validTicketIds.length} 张工单`)
} else {
await assignTicket({
ticketId: ticketId!,
toUserId: Number(values.toUserId),
toTeamId: values.toTeamId ? Number(values.toTeamId) : undefined,
reason: values.reason.trim() || undefined,
})
toast.success("处理人已更新")
}
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "指派工单失败")
} finally {
setSaving(false)
}
}
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{ticketIds?.length ? `批量指派工单(${ticketIds.length}` : "指派工单"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="toTeamId"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder={loading ? "加载中..." : "选择处理团队"}
options={[
{ value: "", label: "不指定团队" },
...teams.map((team) => ({
value: String(team.id),
label: team.name,
})),
]}
/>
)}
/>
</FieldContent>
</Field>
<Field data-invalid={!!errors.toUserId}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="toUserId"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder={loading ? "加载中..." : "选择处理人"}
options={agents.map((agent) => ({
value: String(agent.userId),
label:
agent.displayName ||
agent.nickname ||
agent.username ||
`客服#${agent.userId}`,
}))}
/>
)}
/>
<FieldError errors={[errors.toUserId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.reason}>
<FieldLabel></FieldLabel>
<FieldContent>
<Textarea rows={4} placeholder="填写指派说明" {...register("reason")} />
<FieldError errors={[errors.reason]} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={saving}>
{saving ? "提交中..." : "确认指派"}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}
@@ -0,0 +1,149 @@
"use client"
import { useEffect, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { z } from "zod/v4"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
import { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin"
import { addTicketCollaborator } from "@/lib/api/ticket"
const schema = z.object({
userId: z.string().trim().min(1, "请选择协作人"),
})
type FormValues = z.infer<typeof schema>
const resolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
type TicketCollaboratorDialogProps = {
open: boolean
ticketId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
export function TicketCollaboratorDialog({
open,
ticketId,
onOpenChange,
onSuccess,
}: TicketCollaboratorDialogProps) {
const [loadingAgents, setLoadingAgents] = useState(false)
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const userOptions = agents.map((agent) => ({
value: String(agent.userId),
label: agent.displayName || agent.nickname || agent.username || `客服 #${agent.userId}`,
}))
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
resolver,
defaultValues: { userId: "" },
})
const {
control,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = form
useEffect(() => {
if (open) {
reset({ userId: "" })
}
}, [open, reset])
useEffect(() => {
if (!open) {
return
}
setLoadingAgents(true)
fetchAgentProfilesAll()
.then((data) => {
setAgents(Array.isArray(data) ? data : [])
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
})
.finally(() => {
setLoadingAgents(false)
})
}, [open])
async function onFormSubmit(values: FormValues) {
if (!ticketId) {
toast.error("工单不存在")
return
}
try {
await addTicketCollaborator({ ticketId, userId: Number(values.userId) })
toast.success("协作人已添加")
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "添加协作人失败")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.userId}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="userId"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={userOptions}
placeholder={loadingAgents ? "加载中..." : "选择协作人"}
searchPlaceholder="搜索客服"
emptyText="暂无可选客服"
disabled={isSubmitting || loadingAgents}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.userId]} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "提交中..." : "确认添加"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,529 @@
"use client"
import {
Building2Icon,
Link2Icon,
MailIcon,
PencilIcon,
PhoneIcon,
UserRoundIcon,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { type CustomerFormSavePayload } from "@/components/customer-form"
import { CustomerFormDialog } from "@/components/customer-form-dialog"
import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { updateCompany, type AdminCompany } from "@/lib/api/company"
import {
fetchCustomer,
saveCustomerProfile,
type AdminCustomer,
} from "@/lib/api/customer"
import {
fetchCustomerContacts,
type AdminCustomerContact,
} from "@/lib/api/customer-contact"
import { Gender, GenderLabels, ContactType, ContactTypeLabels } from "@/lib/generated/enums"
import { cn, formatDateTime } from "@/lib/utils"
function contactTypeLabel(contactType: ContactType | string) {
return ContactTypeLabels[contactType as ContactType] ?? contactType
}
function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) {
const cls = "size-3.5 shrink-0 text-muted-foreground"
switch (contactType) {
case ContactType.Mobile:
return <PhoneIcon className={cls} aria-hidden />
case ContactType.Email:
return <MailIcon className={cls} aria-hidden />
default:
return <Link2Icon className={cls} aria-hidden />
}
}
function DetailRow({
label,
value,
valueClassName,
}: {
label: string
value: string
valueClassName?: string
}) {
const empty = !value.trim()
return (
<div className="flex gap-2.5 text-sm leading-snug">
<span className="w-17 shrink-0 pt-px text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 flex-1 break-all text-foreground",
empty && "text-muted-foreground",
valueClassName,
)}
>
{empty ? "—" : value}
</span>
</div>
)
}
function SectionHeading({
children,
action,
}: {
children: React.ReactNode
action?: React.ReactNode
}) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-medium text-muted-foreground">{children}</h3>
{action}
</div>
)
}
function UnlinkedCustomerEmpty({
ticketId,
onSuccess,
}: {
ticketId: number
onSuccess: () => void | Promise<void>
}) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
return (
<div className="space-y-4 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground"> CRM </p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
</p>
<Button type="button" className="mt-4 gap-2" onClick={() => setLinkDialogOpen(true)}>
<Link2Icon className="size-4" />
</Button>
</div>
<CustomerLinkOrCreateDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
ticketId={ticketId}
onSuccess={onSuccess}
/>
</div>
)
}
function MissingCustomerEmpty({
ticketId,
onSuccess,
}: {
ticketId: number
onSuccess: () => void | Promise<void>
}) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
return (
<div className="space-y-4 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground"></p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
</p>
<Button type="button" className="mt-4 gap-2" onClick={() => setLinkDialogOpen(true)}>
<Link2Icon className="size-4" />
</Button>
</div>
<CustomerLinkOrCreateDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
ticketId={ticketId}
onSuccess={onSuccess}
/>
</div>
)
}
type TicketCustomerPanelProps = {
ticketId: number
customerId?: number
onRefresh: () => void | Promise<void>
}
type TicketLinkedCustomerPanelProps = {
ticketId: number
customerId: number
onRefresh: () => void | Promise<void>
}
export function TicketCustomerPanel({
ticketId,
customerId = 0,
onRefresh,
}: TicketCustomerPanelProps) {
if (customerId <= 0) {
return <UnlinkedCustomerEmpty ticketId={ticketId} onSuccess={onRefresh} />
}
return (
<TicketLinkedCustomerPanel
ticketId={ticketId}
customerId={customerId}
onRefresh={onRefresh}
/>
)
}
function TicketLinkedCustomerPanel({
ticketId,
customerId,
onRefresh,
}: TicketLinkedCustomerPanelProps) {
const linkedCustomerId = customerId
const [loading, setLoading] = useState(true)
const [customer, setCustomer] = useState<AdminCustomer | null>(null)
const [contacts, setContacts] = useState<AdminCustomerContact[]>([])
const [customerEditOpen, setCustomerEditOpen] = useState(false)
const [customerEditSaving, setCustomerEditSaving] = useState(false)
const [companyEditOpen, setCompanyEditOpen] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const c = await fetchCustomer(linkedCustomerId)
setCustomer(c)
if (!c) {
setContacts([])
return
}
const list = await fetchCustomerContacts(linkedCustomerId)
setContacts(Array.isArray(list) ? list : [])
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客户信息失败")
setCustomer(null)
setContacts([])
} finally {
setLoading(false)
}
}, [linkedCustomerId])
useEffect(() => {
void load()
}, [load])
if (loading && !customer) {
return <p className="pt-4 text-sm text-muted-foreground"></p>
}
if (!customer) {
return <MissingCustomerEmpty ticketId={ticketId} onSuccess={onRefresh} />
}
const displayName = customer.name.trim() || "未填写姓名"
const company = customer.company ?? null
const genderLabel =
customer.gender === Gender.Male || customer.gender === Gender.Female
? GenderLabels[customer.gender as Gender] ?? String(customer.gender)
: null
const isProfileEmpty =
!customer.name.trim() &&
!customer.primaryMobile.trim() &&
!customer.primaryEmail.trim() &&
customer.companyId === 0 &&
!customer.remark.trim()
return (
<div className="space-y-4 pt-2">
{isProfileEmpty ? (
<div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-relaxed text-amber-950 dark:text-amber-100">
</div>
) : null}
<section className="space-y-2">
<SectionHeading
action={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
onClick={() => setCustomerEditOpen(true)}
>
<PencilIcon className="size-3.5" />
</Button>
}
>
</SectionHeading>
<div className="flex min-w-0 items-start gap-2 text-sm">
<UserRoundIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="line-clamp-2 leading-snug text-foreground">
<span className="font-medium">{displayName}</span>
{genderLabel ? (
<span className="font-normal text-muted-foreground"> · {genderLabel}</span>
) : null}
</p>
</div>
</div>
<div className="space-y-2">
<DetailRow label="手机" value={customer.primaryMobile || ""} />
<DetailRow label="邮箱" value={customer.primaryEmail || ""} />
<DetailRow
label="最近活跃"
value={customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""}
/>
<DetailRow
label="备注"
value={customer.remark.trim() ? customer.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
</div>
</section>
<section className="space-y-2">
<SectionHeading></SectionHeading>
{contacts.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<ul className="space-y-3">
{contacts.map((row) => {
const tags: string[] = []
if (row.isPrimary) tags.push("主")
if (row.isVerified) tags.push("已验证")
return (
<li key={row.id} className="text-sm">
<div className="flex items-center gap-2">
<ContactTypeIcon contactType={row.contactType} />
<div className="min-w-0 flex-1">
<p className="break-all font-medium leading-snug text-foreground">
{row.contactValue}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{contactTypeLabel(row.contactType)}
</span>
{tags.length > 0 ? (
<span className="ml-2 text-xs text-muted-foreground">
{tags.join(" · ")}
</span>
) : null}
</p>
{row.remark ? (
<p className="mt-1 line-clamp-3 break-all text-xs leading-relaxed text-muted-foreground">
{row.remark}
</p>
) : null}
</div>
</div>
</li>
)
})}
</ul>
)}
</section>
<section className="space-y-2 border-t pt-2">
<SectionHeading
action={
company ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
onClick={() => setCompanyEditOpen(true)}
>
<PencilIcon className="size-3.5" />
</Button>
) : null
}
>
</SectionHeading>
{company ? (
<div className="space-y-2">
<div className="flex min-w-0 items-start gap-2 text-sm">
<Building2Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="line-clamp-2 font-medium leading-snug text-foreground">
{company.name}
</p>
{company.code ? (
<p className="font-mono text-xs text-muted-foreground">{company.code}</p>
) : null}
</div>
</div>
<div className="space-y-2 pt-1">
<DetailRow label="创建" value={formatDateTime(company.createdAt)} />
<DetailRow label="更新" value={formatDateTime(company.updatedAt)} />
<DetailRow
label="备注"
value={company.remark.trim() ? company.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
</p>
)}
</section>
<CustomerFormDialog
open={customerEditOpen}
onOpenChange={setCustomerEditOpen}
saving={customerEditSaving}
itemId={customer.id}
onSave={async (payload: CustomerFormSavePayload) => {
if (customerEditSaving) {
return
}
setCustomerEditSaving(true)
try {
await saveCustomerProfile({ ...payload, id: customer.id })
toast.success("已保存")
await load()
await onRefresh()
setCustomerEditOpen(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败")
} finally {
setCustomerEditSaving(false)
}
}}
/>
{company ? (
<CompanyEditDialog
open={companyEditOpen}
onOpenChange={setCompanyEditOpen}
company={company}
onSaved={async () => {
await load()
await onRefresh()
}}
/>
) : null}
</div>
)
}
type CompanyEditDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
company: AdminCompany
onSaved: () => void | Promise<void>
}
function CompanyEditDialog({
open,
onOpenChange,
company,
onSaved,
}: CompanyEditDialogProps) {
const [name, setName] = useState("")
const [code, setCode] = useState("")
const [remark, setRemark] = useState("")
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) {
return
}
setName(company.name)
setCode(company.code)
setRemark(company.remark)
}, [open, company])
const handleSubmit = async () => {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error("公司名称不能为空")
return
}
setSaving(true)
try {
await updateCompany({
id: company.id,
name: trimmedName,
code: code.trim(),
remark: remark.trim(),
})
toast.success("已保存")
await onSaved()
onOpenChange(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-1">
<Field orientation="vertical">
<FieldLabel htmlFor="ticket-company-name"></FieldLabel>
<FieldContent>
<Input
id="ticket-company-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="ticket-company-code"></FieldLabel>
<FieldContent>
<Input
id="ticket-company-code"
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="ticket-company-remark"></FieldLabel>
<FieldContent>
<Textarea
id="ticket-company-remark"
value={remark}
onChange={(event) => setRemark(event.target.value)}
rows={3}
/>
</FieldContent>
</Field>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? "保存中…" : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,43 @@
"use client"
import { useEffect, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { getTicketPriorityMap } from "@/lib/ticket-priority"
const priorityClassNameMap: Record<number, string> = {
0: "bg-slate-500/10 text-slate-700 border-slate-500/20",
1: "bg-blue-500/10 text-blue-700 border-blue-500/20",
2: "bg-amber-500/10 text-amber-700 border-amber-500/20",
3: "bg-red-500/10 text-red-700 border-red-500/20",
4: "bg-fuchsia-500/10 text-fuchsia-700 border-fuchsia-500/20",
}
export function ticketPriorityLabel(priority: number, priorityName?: string) {
return priorityName?.trim() || `P${priority}`
}
export function TicketPriorityBadge({
priority,
priorityName,
}: {
priority: number
priorityName?: string
}) {
const [priorityMap, setPriorityMap] = useState<Record<number, string>>({})
useEffect(() => {
void (async () => {
setPriorityMap(await getTicketPriorityMap())
})()
}, [])
return (
<Badge
variant="outline"
className={priorityClassNameMap[priority] ?? priorityClassNameMap[0]}
>
{ticketPriorityLabel(priority, priorityName || priorityMap[priority])}
</Badge>
)
}
@@ -0,0 +1,122 @@
"use client"
import { useEffect } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import { closeTicket, reopenTicket } from "@/lib/api/ticket"
const schema = z.object({
reason: z.string().trim().min(1, "请输入原因"),
})
type FormValues = z.infer<typeof schema>
const resolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
type TicketReasonDialogProps = {
open: boolean
mode: "close" | "reopen"
ticketId: number | null
defaultReason?: string
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
export function TicketReasonDialog({
open,
mode,
ticketId,
defaultReason,
onOpenChange,
onSuccess,
}: TicketReasonDialogProps) {
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
resolver,
defaultValues: { reason: "" },
})
const {
register,
reset,
handleSubmit,
formState: { errors, isSubmitting },
} = form
useEffect(() => {
reset({ reason: defaultReason || "" })
}, [defaultReason, reset, ticketId, open])
async function onFormSubmit(values: FormValues) {
if (!ticketId) {
toast.error("工单不存在")
return
}
try {
if (mode === "close") {
await closeTicket({ ticketId, closeReason: values.reason })
toast.success("工单已关闭")
} else {
await reopenTicket({ ticketId, reason: values.reason })
toast.success("工单已重开")
}
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : mode === "close" ? "关闭工单失败" : "重开工单失败")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{mode === "close" ? "关闭工单" : "重开工单"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.reason}>
<FieldLabel>{mode === "close" ? "关闭原因" : "重开原因"}</FieldLabel>
<FieldContent>
<Textarea
rows={4}
placeholder={mode === "close" ? "请输入关闭原因" : "请输入重开原因"}
{...register("reason")}
/>
<FieldError errors={[errors.reason]} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "提交中..." : mode === "close" ? "确认关闭" : "确认重开"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,229 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
import { SearchIcon } from "lucide-react"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { addTicketRelation, fetchTickets, type TicketItem } from "@/lib/api/ticket"
import { cn } from "@/lib/utils"
const relationOptions = [
{ value: "duplicate", label: "重复工单" },
{ value: "related", label: "相关工单" },
{ value: "parent", label: "父工单" },
{ value: "child", label: "子工单" },
]
const schema = z.object({
relationType: z.string().trim().min(1, "请选择关联类型"),
relatedTicketId: z.number().int().positive("请选择关联工单"),
})
type FormValues = z.infer<typeof schema>
const resolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
type TicketRelationDialogProps = {
open: boolean
ticketId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
export function TicketRelationDialog({
open,
ticketId,
onOpenChange,
onSuccess,
}: TicketRelationDialogProps) {
const [keyword, setKeyword] = useState("")
const [searching, setSearching] = useState(false)
const [searchResults, setSearchResults] = useState<TicketItem[]>([])
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
resolver,
defaultValues: {
relationType: "related",
relatedTicketId: 0,
},
})
const {
handleSubmit,
setValue,
reset,
watch,
formState: { errors, isSubmitting },
} = form
useEffect(() => {
if (open) {
reset({ relationType: "related", relatedTicketId: 0 })
setKeyword("")
setSearchResults([])
}
}, [open, reset])
useEffect(() => {
if (!open) {
return
}
const trimmedKeyword = keyword.trim()
if (trimmedKeyword.length < 2) {
setSearchResults([])
return
}
const timer = window.setTimeout(async () => {
setSearching(true)
try {
const data = await fetchTickets({
keyword: trimmedKeyword,
page: 1,
limit: 8,
})
const results = Array.isArray(data.results) ? data.results : []
setSearchResults(results.filter((item) => item.id !== ticketId))
} catch (error) {
toast.error(error instanceof Error ? error.message : "搜索工单失败")
} finally {
setSearching(false)
}
}, 250)
return () => window.clearTimeout(timer)
}, [keyword, open, ticketId])
const selectedTicketId = watch("relatedTicketId")
const selectedTicket =
searchResults.find((item) => item.id === selectedTicketId) ?? null
async function onFormSubmit(values: FormValues) {
if (!ticketId) {
toast.error("工单不存在")
return
}
try {
await addTicketRelation({
ticketId,
relationType: values.relationType,
relatedTicketId: values.relatedTicketId,
})
toast.success("关联工单已添加")
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "添加关联工单失败")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.relationType}>
<FieldLabel></FieldLabel>
<FieldContent>
<OptionCombobox
value={watch("relationType")}
options={relationOptions}
placeholder="请选择关联类型"
onChange={(value) => setValue("relationType", value, { shouldValidate: true })}
/>
<FieldError errors={[errors.relationType]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.relatedTicketId}>
<FieldLabel></FieldLabel>
<FieldContent>
<div className="space-y-3">
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
value={keyword}
placeholder="输入工单号或标题,至少 2 个字"
onChange={(event) => {
setKeyword(event.target.value)
setValue("relatedTicketId", 0, { shouldValidate: true })
}}
/>
</div>
<div className="max-h-64 overflow-y-auto rounded-lg border">
{searching ? (
<div className="p-3 text-sm text-muted-foreground">...</div>
) : searchResults.length > 0 ? (
searchResults.map((item) => {
const active = item.id === selectedTicketId
return (
<button
key={item.id}
type="button"
className={cn(
"flex w-full flex-col items-start gap-1 border-b px-3 py-3 text-left last:border-b-0",
active ? "bg-accent text-accent-foreground" : "hover:bg-muted/40",
)}
onClick={() => setValue("relatedTicketId", item.id, { shouldValidate: true })}
>
<div className="text-xs text-muted-foreground">{item.ticketNo}</div>
<div className="line-clamp-1 text-sm font-medium">{item.title}</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{item.status}</span>
<span>{item.currentAssigneeName || "未指派"}</span>
</div>
</button>
)
})
) : (
<div className="p-3 text-sm text-muted-foreground">
{keyword.trim().length < 2 ? "输入至少 2 个字开始搜索" : "未找到匹配工单"}
</div>
)}
</div>
{selectedTicket ? (
<div className="rounded-lg border bg-muted/20 p-3 text-sm">
{selectedTicket.ticketNo} / {selectedTicket.title}
</div>
) : null}
</div>
<FieldError errors={[errors.relatedTicketId]} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "提交中..." : "确认添加"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,244 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MessageSquarePlusIcon } from "lucide-react"
import { toast } from "sonner"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin"
import { addTicketInternalNote, replyTicket } from "@/lib/api/ticket"
type TicketReplyDialogProps = {
open: boolean
ticketId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
export function TicketReplyDialog({
open,
ticketId,
onOpenChange,
onSuccess,
}: TicketReplyDialogProps) {
const [replyMode, setReplyMode] = useState<"public" | "internal">("public")
const [replyContent, setReplyContent] = useState("")
const [mentionUserId, setMentionUserId] = useState("")
const [mentionedUsers, setMentionedUsers] = useState<AdminAgentProfile[]>([])
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const [loadingAgents, setLoadingAgents] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!open) {
return
}
setReplyMode("public")
setReplyContent("")
setMentionUserId("")
setMentionedUsers([])
}, [open])
useEffect(() => {
if (!open) {
return
}
setLoadingAgents(true)
fetchAgentProfilesAll()
.then((data) => {
setAgents(Array.isArray(data) ? data : [])
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
})
.finally(() => {
setLoadingAgents(false)
})
}, [open])
const mentionOptions = useMemo(
() =>
agents.map((agent) => ({
value: String(agent.userId),
label:
agent.displayName ||
agent.nickname ||
agent.username ||
`客服 #${agent.userId}`,
})),
[agents],
)
function handleAddMentionUser() {
const userId = Number(mentionUserId)
if (!userId) {
return
}
const user = agents.find((item) => item.userId === userId)
if (!user) {
return
}
setMentionedUsers((current) => {
if (current.some((item) => item.userId === user.userId)) {
return current
}
return [...current, user]
})
setMentionUserId("")
}
async function handleSubmit() {
if (!ticketId) {
toast.error("工单不存在")
return
}
if (!replyContent.trim()) {
toast.error(replyMode === "public" ? "回复内容不能为空" : "备注内容不能为空")
return
}
setSubmitting(true)
try {
if (replyMode === "public") {
await replyTicket({
ticketId,
contentType: "text",
content: replyContent.trim(),
})
toast.success("已回复客户")
} else {
const payload =
mentionedUsers.length > 0
? JSON.stringify({
mentionUserIds: mentionedUsers.map((item) => item.userId),
})
: undefined
await addTicketInternalNote({
ticketId,
contentType: "text",
content: replyContent.trim(),
payload,
})
toast.success("已添加内部备注")
}
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交失败")
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-2xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 p-6">
<div className="flex gap-2">
<Button
variant={replyMode === "public" ? "default" : "outline"}
onClick={() => setReplyMode("public")}
disabled={submitting}
>
</Button>
<Button
variant={replyMode === "internal" ? "default" : "outline"}
onClick={() => setReplyMode("internal")}
disabled={submitting}
>
</Button>
</div>
<Textarea
rows={8}
value={replyContent}
placeholder={replyMode === "public" ? "输入给客户的回复内容" : "输入内部备注"}
disabled={submitting}
onChange={(event) => setReplyContent(event.target.value)}
/>
{replyMode === "internal" ? (
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="text-sm font-medium">@提及协作人</div>
<div className="flex gap-2">
<div className="flex-1">
<OptionCombobox
value={mentionUserId}
options={mentionOptions}
placeholder={loadingAgents ? "加载中..." : "选择要提及的客服"}
searchPlaceholder="搜索客服"
emptyText="暂无可选客服"
disabled={submitting || loadingAgents}
onChange={setMentionUserId}
/>
</div>
<Button
type="button"
variant="outline"
disabled={submitting || loadingAgents}
onClick={handleAddMentionUser}
>
</Button>
</div>
{mentionedUsers.length ? (
<div className="flex flex-wrap gap-2">
{mentionedUsers.map((user) => (
<button
key={user.userId}
type="button"
className="rounded-full border px-3 py-1 text-xs"
onClick={() =>
setMentionedUsers((current) =>
current.filter((item) => item.userId !== user.userId),
)
}
>
@
{user.displayName ||
user.nickname ||
user.username ||
`客服#${user.userId}`}{" "}
×
</button>
))}
</div>
) : (
<div className="text-xs text-muted-foreground"></div>
)}
</div>
) : null}
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button
type="button"
variant="outline"
disabled={submitting}
onClick={() => onOpenChange(false)}
>
</Button>
<Button type="button" disabled={submitting} onClick={() => void handleSubmit()}>
<MessageSquarePlusIcon className="size-4" />
{submitting ? "提交中..." : replyMode === "public" ? "发送回复" : "保存备注"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,62 @@
"use client"
import { Badge } from "@/components/ui/badge"
import type { TicketItem } from "@/lib/api/ticket"
function isClosedStatus(status: string) {
return status === "resolved" || status === "closed" || status === "cancelled"
}
export function TicketSLABadge({ ticket }: { ticket: TicketItem }) {
if (isClosedStatus(ticket.status)) {
return (
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
</Badge>
)
}
if (!ticket.resolveDeadlineAt) {
return (
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
</Badge>
)
}
const deadline = new Date(ticket.resolveDeadlineAt.replace(" ", "T"))
if (Number.isNaN(deadline.getTime())) {
return (
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
</Badge>
)
}
const remainingMinutes = Math.floor((deadline.getTime() - Date.now()) / 60000)
if (remainingMinutes < 0) {
return (
<Badge variant="outline" className="border-red-500/20 bg-red-500/10 text-red-700">
</Badge>
)
}
if (remainingMinutes <= 60) {
return (
<Badge variant="outline" className="border-red-500/20 bg-red-500/10 text-red-700">
1
</Badge>
)
}
if (remainingMinutes <= 240) {
return (
<Badge variant="outline" className="border-amber-500/20 bg-amber-500/10 text-amber-700">
</Badge>
)
}
return (
<Badge variant="outline" className="border-emerald-500/20 bg-emerald-500/10 text-emerald-700">
</Badge>
)
}
@@ -0,0 +1,35 @@
"use client"
import { Badge } from "@/components/ui/badge"
const statusLabelMap: Record<string, string> = {
new: "新建",
open: "处理中",
pending_customer: "待客户反馈",
pending_internal: "待内部处理",
resolved: "已解决",
closed: "已关闭",
cancelled: "已取消",
}
const statusClassNameMap: Record<string, string> = {
new: "bg-sky-500/10 text-sky-700 border-sky-500/20",
open: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20",
pending_customer: "bg-amber-500/10 text-amber-700 border-amber-500/20",
pending_internal: "bg-orange-500/10 text-orange-700 border-orange-500/20",
resolved: "bg-lime-500/10 text-lime-700 border-lime-500/20",
closed: "bg-muted text-muted-foreground border-border",
cancelled: "bg-rose-500/10 text-rose-700 border-rose-500/20",
}
export function ticketStatusLabel(status: string) {
return statusLabelMap[status] ?? status
}
export function TicketStatusBadge({ status }: { status: string }) {
return (
<Badge variant="outline" className={statusClassNameMap[status] ?? statusClassNameMap.closed}>
{ticketStatusLabel(status)}
</Badge>
)
}
@@ -0,0 +1,279 @@
"use client"
import Link from "next/link"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Settings2Icon } from "lucide-react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import {
fetchTicketResolutionCodesAll,
type TicketResolutionCode,
} from "@/lib/api/ticket-config"
import { batchChangeTicketStatus, changeTicketStatus } from "@/lib/api/ticket"
const schema = z.object({
status: z.string().trim().min(1, "请选择状态"),
pendingReason: z.string().trim(),
closeReason: z.string().trim(),
resolutionCode: z.string().trim(),
resolutionSummary: z.string().trim(),
reason: z.string().trim(),
})
type FormValues = z.infer<typeof schema>
const resolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
type TicketStatusDialogProps = {
open: boolean
ticketId: number | null
ticketIds?: number[]
currentStatus?: string
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
export function TicketStatusDialog({
open,
ticketId,
ticketIds,
currentStatus,
onOpenChange,
onSuccess,
}: TicketStatusDialogProps) {
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
resolver,
defaultValues: {
status: "",
pendingReason: "",
closeReason: "",
resolutionCode: "",
resolutionSummary: "",
reason: "",
},
})
const {
control,
watch,
register,
reset,
handleSubmit,
formState: { errors, isSubmitting },
} = form
const [resolutionCodes, setResolutionCodes] = useState<TicketResolutionCode[]>([])
const targetStatus = watch("status")
useEffect(() => {
reset({
status: currentStatus || "",
pendingReason: "",
closeReason: "",
resolutionCode: "",
resolutionSummary: "",
reason: "",
})
}, [currentStatus, reset, ticketId])
useEffect(() => {
if (!open) {
return
}
void (async () => {
try {
const data = await fetchTicketResolutionCodesAll()
setResolutionCodes(Array.isArray(data) ? data : [])
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载解决码失败")
}
})()
}, [open])
const resolutionCodeOptions = resolutionCodes.map((item) => ({
value: item.code,
label: item.name,
}))
async function onFormSubmit(values: FormValues) {
const validTicketIds = (ticketIds ?? []).filter((item) => item > 0)
if (!ticketId && validTicketIds.length === 0) {
toast.error("请选择工单")
return
}
try {
if (validTicketIds.length > 0) {
await batchChangeTicketStatus({
ticketIds: validTicketIds,
status: values.status,
pendingReason: values.pendingReason || undefined,
closeReason: values.status === "closed" ? values.closeReason || undefined : undefined,
resolutionCode: values.resolutionCode || undefined,
resolutionSummary: values.resolutionSummary || undefined,
reason: values.reason || undefined,
})
toast.success(`已批量更新 ${validTicketIds.length} 张工单`)
} else {
await changeTicketStatus({
ticketId: ticketId!,
status: values.status,
pendingReason: values.pendingReason || undefined,
closeReason: values.status === "closed" ? values.closeReason || undefined : undefined,
resolutionCode: values.resolutionCode || undefined,
resolutionSummary: values.resolutionSummary || undefined,
reason: values.reason || undefined,
})
toast.success("状态已更新")
}
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{ticketIds?.length ? `批量变更状态(${ticketIds.length}` : "变更工单状态"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.status}>
<FieldLabel></FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder="请选择状态"
options={[
{ value: "new", label: "新建" },
{ value: "open", label: "处理中" },
{ value: "pending_customer", label: "待客户反馈" },
{ value: "pending_internal", label: "待内部处理" },
{ value: "resolved", label: "已解决" },
{ value: "closed", label: "已关闭" },
{ value: "cancelled", label: "已取消" },
]}
/>
)}
/>
<FieldError errors={[errors.status]} />
</FieldContent>
</Field>
{(targetStatus === "pending_customer" ||
targetStatus === "pending_internal") && (
<Field data-invalid={!!errors.pendingReason}>
<FieldLabel></FieldLabel>
<FieldContent>
<Textarea rows={3} placeholder="请输入待处理原因" {...register("pendingReason")} />
<FieldError errors={[errors.pendingReason]} />
</FieldContent>
</Field>
)}
{targetStatus === "resolved" && (
<>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel></FieldLabel>
</div>
<FieldContent>
<Controller
control={control}
name="resolutionCode"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder="请选择解决编码"
options={resolutionCodeOptions}
emptyText="暂无可选解决码"
/>
)}
/>
{resolutionCodeOptions.length === 0 ? (
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50/70 p-3 text-xs text-amber-900">
<Link
href="/ticket-resolution-codes"
target="_blank"
rel="noreferrer"
className="ml-1 font-medium underline underline-offset-4"
>
</Link>
</div>
) : null}
</FieldContent>
</Field>
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<Textarea rows={3} placeholder="请输入解决说明" {...register("resolutionSummary")} />
</FieldContent>
</Field>
</>
)}
{targetStatus === "closed" && (
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<Textarea rows={3} placeholder="请输入关闭原因" {...register("closeReason")} />
</FieldContent>
</Field>
)}
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<Textarea
rows={3}
placeholder={targetStatus === "closed" ? "可补充本次批量关闭说明" : "填写本次状态变更说明"}
{...register("reason")}
/>
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "提交中..." : "确认变更"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,335 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { SearchIcon, ShieldAlertIcon, ShieldCheckIcon, ShieldIcon } from "lucide-react"
import { z } from "zod/v4"
import type { AdminRole, AdminUser } from "@/lib/api/admin"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Status } from "@/lib/generated/enums"
import { cn } from "@/lib/utils"
type AssignRolesDrawerProps = {
open: boolean
saving: boolean
loading: boolean
item: AdminUser | null
roles: AdminRole[]
selectedRoleIds: number[]
onOpenChange: (open: boolean) => void
onSubmit: (roleIds: number[]) => Promise<void>
}
const assignRolesSchema = z.object({
roleIds: z.array(z.number().int().positive()),
})
type AssignRolesForm = z.infer<typeof assignRolesSchema>
const assignRolesResolver = zodResolver(assignRolesSchema as never) as Resolver<
z.input<typeof assignRolesSchema>,
undefined,
z.output<typeof assignRolesSchema>
>
function buildForm(selectedRoleIds: number[]): AssignRolesForm {
return {
roleIds: selectedRoleIds,
}
}
export function AssignRolesDrawer({
open,
saving,
loading,
item,
roles,
selectedRoleIds,
onOpenChange,
onSubmit,
}: AssignRolesDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
{open ? (
<AssignRolesDrawerBody
key={item ? `assign-roles-${item.id}` : "assign-roles"}
saving={saving}
loading={loading}
item={item}
roles={roles}
selectedRoleIds={selectedRoleIds}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
) : null}
</Drawer>
)
}
type AssignRolesDrawerBodyProps = {
saving: boolean
loading: boolean
item: AdminUser | null
roles: AdminRole[]
selectedRoleIds: number[]
onOpenChange: (open: boolean) => void
onSubmit: (roleIds: number[]) => Promise<void>
}
function AssignRolesDrawerBody({
saving,
loading,
item,
roles,
selectedRoleIds,
onOpenChange,
onSubmit,
}: AssignRolesDrawerBodyProps) {
const [keyword, setKeyword] = useState("")
const form = useForm<
z.input<typeof assignRolesSchema>,
undefined,
z.output<typeof assignRolesSchema>
>({
resolver: assignRolesResolver,
defaultValues: buildForm(selectedRoleIds),
})
const {
control,
handleSubmit,
reset,
formState: { errors },
} = form
useEffect(() => {
reset(buildForm(selectedRoleIds))
}, [reset, selectedRoleIds])
const roleMap = useMemo(
() => new Map(roles.map((role) => [role.id, role])),
[roles]
)
async function onFormSubmit(values: AssignRolesForm) {
await onSubmit(values.roleIds)
}
return (
<DrawerContent className="flex min-w-2xl flex-col overflow-hidden">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
</DrawerHeader>
<form
className="flex min-h-0 flex-1 flex-col overflow-hidden"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="min-h-0 flex-1 overflow-y-auto">
<Controller
control={control}
name="roleIds"
render={({ field }) => {
const value = field.value || []
const selectedRoleSet = new Set(value)
const initiallySelectedSet = new Set(selectedRoleIds)
const selectedRoles = roles.filter((role) => selectedRoleSet.has(role.id))
const removedRoles = selectedRoleIds
.map((roleId) => roleMap.get(roleId))
.filter((role): role is AdminRole => !!role && !selectedRoleSet.has(role.id))
const addedRoles = value
.map((roleId) => roleMap.get(roleId))
.filter((role): role is AdminRole => !!role && !initiallySelectedSet.has(role.id))
const filteredRoles = roles.filter((role) => {
const output = keyword.trim().toLowerCase()
if (!output) {
return true
}
return `${role.name} ${role.code}`.toLowerCase().includes(output)
})
return (
<div className="space-y-4 px-4 pb-4">
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<div className="rounded-lg border p-3">
{selectedRoles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedRoles.map((role) => (
<Badge
key={role.id}
variant={role.status === Status.Ok ? "secondary" : "outline"}
className="gap-1"
>
{role.status === Status.Ok ? (
<ShieldCheckIcon className="size-3" />
) : (
<ShieldAlertIcon className="size-3" />
)}
{role.name}
{role.status !== Status.Ok ? "(已禁用)" : ""}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground"></div>
)}
</div>
</FieldContent>
</Field>
<Field data-invalid={!!errors.roleIds}>
<FieldLabel></FieldLabel>
<FieldContent>
<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={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索角色名称或编码"
className="pl-9"
disabled={loading}
/>
</div>
<div className="mt-2 max-h-[360px] space-y-1 overflow-y-auto rounded-lg border p-2">
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
</div>
) : filteredRoles.length > 0 ? (
filteredRoles.map((role) => {
const checked = selectedRoleSet.has(role.id)
const disabled = role.status !== Status.Ok && !checked
return (
<label
key={role.id}
className={cn(
"flex items-center gap-2 rounded-md border px-2.5 py-2 text-sm transition-colors",
disabled
? "cursor-not-allowed border-dashed bg-muted/20 opacity-70"
: "cursor-pointer hover:bg-muted/50",
checked && "border-primary/40 bg-primary/5"
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(nextChecked) => {
if (nextChecked) {
field.onChange([...value, role.id])
return
}
field.onChange(
value.filter((currentId) => currentId !== role.id)
)
}}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 whitespace-nowrap">
<span className="truncate font-medium">{role.name}</span>
<span className="truncate text-muted-foreground">
{role.code}
</span>
</div>
</div>
{role.isSystem ? (
<Badge variant="outline" className="shrink-0">
</Badge>
) : null}
<Badge
variant={role.status === Status.Ok ? "secondary" : "outline"}
className="shrink-0"
>
{role.status === Status.Ok ? "启用" : "禁用"}
</Badge>
</label>
)
})
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
)}
</div>
<FieldError errors={[errors.roleIds]} />
</FieldContent>
</Field>
<Field>
<FieldLabel></FieldLabel>
<FieldContent>
<div className="space-y-3 rounded-lg border p-3">
<div>
<div className="mb-2 text-sm font-medium"></div>
{addedRoles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{addedRoles.map((role) => (
<Badge key={role.id} variant="secondary" className="gap-1">
<ShieldIcon className="size-3" />
{role.name}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground"></div>
)}
</div>
<div>
<div className="mb-2 text-sm font-medium"></div>
{removedRoles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{removedRoles.map((role) => (
<Badge key={role.id} variant="outline" className="gap-1">
<ShieldIcon className="size-3" />
{role.name}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground"></div>
)}
</div>
</div>
</FieldContent>
</Field>
</div>
)
}}
/>
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || loading || !item}>
{saving ? "保存中..." : "确认分配"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
</DrawerFooter>
</form>
</DrawerContent>
)
}
@@ -0,0 +1,374 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { SearchIcon, ShieldAlertIcon, ShieldCheckIcon } from "lucide-react"
import { z } from "zod/v4"
import {
fetchRoleListAll,
type AdminRole,
type CreateAdminUserPayload,
} from "@/lib/api/admin"
import { Status } from "@/lib/generated/enums"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
type CreateUserDrawerProps = {
open: boolean
saving: boolean
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateAdminUserPayload) => Promise<void>
}
const createFormSchema = z.object({
username: z.string().trim().min(1, "用户名不能为空"),
nickname: z.string().trim(),
avatar: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^https?:\/\/\S+$/i.test(value),
"头像地址必须是 http 或 https 链接"
),
mobile: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^[0-9+\-\s]{6,20}$/.test(value),
"手机号格式不正确"
),
email: z
.string()
.trim()
.refine(
(value) =>
value.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
"邮箱格式不正确"
),
remark: z.string().trim(),
roleIds: z.array(z.number().int().positive()),
})
type CreateForm = z.infer<typeof createFormSchema>
const emptyForm: CreateForm = {
username: "",
nickname: "",
avatar: "",
mobile: "",
email: "",
remark: "",
roleIds: [],
}
const createFormResolver = zodResolver(createFormSchema as never) as Resolver<
z.input<typeof createFormSchema>,
undefined,
z.output<typeof createFormSchema>
>
function toNullableString(value: string) {
const output = value.trim()
return output ? output : null
}
function buildPayload(form: CreateForm): CreateAdminUserPayload {
return {
username: form.username.trim(),
nickname: form.nickname.trim(),
avatar: form.avatar.trim(),
mobile: toNullableString(form.mobile),
email: toNullableString(form.email),
remark: form.remark.trim(),
roleIds: form.roleIds,
}
}
export function CreateUserDrawer({
open,
saving,
onOpenChange,
onSubmit,
}: CreateUserDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
{open ? (
<CreateUserDrawerBody
key="create-user"
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
) : null}
</Drawer>
)
}
type CreateUserDrawerBodyProps = {
saving: boolean
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateAdminUserPayload) => Promise<void>
}
function CreateUserDrawerBody({
saving,
onOpenChange,
onSubmit,
}: CreateUserDrawerBodyProps) {
const [rolesLoading, setRolesLoading] = useState(true)
const [roles, setRoles] = useState<AdminRole[]>([])
const [roleKeyword, setRoleKeyword] = useState("")
const form = useForm<
z.input<typeof createFormSchema>,
undefined,
z.output<typeof createFormSchema>
>({
resolver: createFormResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
register,
reset,
formState: { errors },
} = form
useEffect(() => {
async function loadRoles() {
setRolesLoading(true)
try {
const list = await fetchRoleListAll()
setRoles(list)
} catch {
setRoles([])
} finally {
setRolesLoading(false)
}
}
void loadRoles()
}, [])
const filteredRoles = useMemo(() => {
const q = roleKeyword.trim().toLowerCase()
if (!q) {
return roles
}
return roles.filter((role) =>
`${role.name} ${role.code}`.toLowerCase().includes(q)
)
}, [roleKeyword, roles])
async function onFormSubmit(values: CreateForm) {
await onSubmit(buildPayload(values))
reset(emptyForm)
setRoleKeyword("")
}
return (
<DrawerContent className="min-w-2xl">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerDescription>
</DrawerDescription>
</DrawerHeader>
<form
className="flex h-full flex-col"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="space-y-4 overflow-y-auto px-4 pb-4">
<Field data-invalid={!!errors.username}>
<FieldLabel htmlFor="create-username"></FieldLabel>
<FieldContent>
<Input
id="create-username"
placeholder="登录名,必填"
autoComplete="off"
aria-invalid={!!errors.username}
{...register("username")}
/>
<FieldError errors={[errors.username]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.nickname}>
<FieldLabel htmlFor="create-nickname"></FieldLabel>
<FieldContent>
<Input
id="create-nickname"
placeholder="可选,默认同用户名"
aria-invalid={!!errors.nickname}
{...register("nickname")}
/>
<FieldError errors={[errors.nickname]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.avatar}>
<FieldLabel htmlFor="create-avatar"></FieldLabel>
<FieldContent>
<Input
id="create-avatar"
placeholder="可选,http(s) 链接"
aria-invalid={!!errors.avatar}
{...register("avatar")}
/>
<FieldError errors={[errors.avatar]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.mobile}>
<FieldLabel htmlFor="create-mobile"></FieldLabel>
<FieldContent>
<Input
id="create-mobile"
placeholder="可选"
aria-invalid={!!errors.mobile}
{...register("mobile")}
/>
<FieldError errors={[errors.mobile]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="create-email"></FieldLabel>
<FieldContent>
<Input
id="create-email"
placeholder="可选"
aria-invalid={!!errors.email}
{...register("email")}
/>
<FieldError errors={[errors.email]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="create-remark"></FieldLabel>
<FieldContent>
<Input
id="create-remark"
placeholder="可选"
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.roleIds}>
<FieldLabel></FieldLabel>
<FieldContent>
<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={roleKeyword}
onChange={(event) => setRoleKeyword(event.target.value)}
placeholder="搜索角色"
className="pl-9"
disabled={rolesLoading}
/>
</div>
<Controller
control={control}
name="roleIds"
render={({ field }) => {
const value = field.value || []
const selectedSet = new Set(value)
return (
<div className="mt-2 max-h-[240px] space-y-1 overflow-y-auto rounded-lg border p-2">
{rolesLoading ? (
<div className="py-6 text-center text-sm text-muted-foreground">
...
</div>
) : filteredRoles.length > 0 ? (
filteredRoles.map((role) => {
const checked = selectedSet.has(role.id)
const disabled = role.status !== Status.Ok && !checked
return (
<label
key={role.id}
className={cn(
"flex items-center gap-2 rounded-md border px-2.5 py-2 text-sm transition-colors",
disabled
? "cursor-not-allowed border-dashed bg-muted/20 opacity-70"
: "cursor-pointer hover:bg-muted/50",
checked && "border-primary/40 bg-primary/5"
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(nextChecked) => {
if (nextChecked) {
field.onChange([...value, role.id])
return
}
field.onChange(
value.filter(
(id: number) => id !== role.id
)
)
}}
/>
<span className="flex min-w-0 flex-1 items-center gap-2">
{role.status === Status.Ok ? (
<ShieldCheckIcon className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ShieldAlertIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{role.name}</span>
{role.status !== Status.Ok ? (
<Badge variant="outline" className="text-xs">
</Badge>
) : null}
</span>
</label>
)
})
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
)
}}
/>
<FieldError errors={[errors.roleIds]} />
</FieldContent>
</Field>
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || rolesLoading}>
{saving ? "创建中..." : "创建用户"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
</DrawerFooter>
</form>
</DrawerContent>
)
}
@@ -0,0 +1,270 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import {
type AdminUser,
type UpdateAdminUserPayload,
fetchUserDetail,
} from "@/lib/api/admin"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
type UserEditDrawerProps = {
open: boolean
saving: boolean
itemId: number | null
onOpenChange: (open: boolean) => void
onSubmit: (payload: UpdateAdminUserPayload) => Promise<void>
}
const emptyForm: EditForm = {
nickname: "",
avatar: "",
mobile: "",
email: "",
}
const editFormSchema = z.object({
nickname: z.string().trim().min(1, "昵称不能为空"),
avatar: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^https?:\/\/\S+$/i.test(value),
"头像地址必须是 http 或 https 链接"
),
mobile: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^[0-9+\-\s]{6,20}$/.test(value),
"手机号格式不正确"
),
email: z
.string()
.trim()
.refine(
(value) =>
value.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
"邮箱格式不正确"
),
})
type EditForm = z.infer<typeof editFormSchema>
const editFormResolver = zodResolver(editFormSchema as never) as Resolver<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>
function toNullableString(value: string) {
const output = value.trim()
return output ? output : null
}
function buildForm(item: AdminUser | null): EditForm {
if (!item) {
return emptyForm
}
return {
nickname: item.nickname || "",
avatar: item.avatar || "",
mobile: item.mobile || "",
email: item.email || "",
}
}
function buildPayload(userId: number, form: EditForm): UpdateAdminUserPayload {
return {
id: userId,
nickname: form.nickname.trim(),
avatar: form.avatar.trim(),
mobile: toNullableString(form.mobile),
email: toNullableString(form.email),
remark: "",
}
}
export function EditDrawer({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: UserEditDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
{open ? (
<UserEditDrawerBody
key={itemId ? `edit-${itemId}` : "edit"}
itemId={itemId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
) : null}
</Drawer>
)
}
type UserEditDrawerBodyProps = {
saving: boolean
itemId: number | null
onOpenChange: (open: boolean) => void
onSubmit: (payload: UpdateAdminUserPayload) => Promise<void>
}
function UserEditDrawerBody({
saving,
itemId,
onOpenChange,
onSubmit,
}: UserEditDrawerBodyProps) {
const [loading, setLoading] = useState(false)
const [item, setItem] = useState<AdminUser | null>(null)
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
const {
handleSubmit,
reset,
register,
formState: { errors },
} = form
useEffect(() => {
async function loadDetail() {
if (!itemId) {
setItem(null)
reset(emptyForm)
return
}
setLoading(true)
try {
const data = await fetchUserDetail(itemId)
setItem(data)
reset(buildForm(data))
} catch (error) {
console.error("Failed to load user:", error)
} finally {
setLoading(false)
}
}
void loadDetail()
}, [itemId, reset])
async function onFormSubmit(values: EditForm) {
if (!itemId) {
return
}
await onSubmit(buildPayload(itemId, values))
}
return (
<DrawerContent className="min-w-2xl">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerDescription>{item?.username || "-"}</DrawerDescription>
</DrawerHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form
className="flex h-full flex-col"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="space-y-4 px-4 pb-4">
<Field data-invalid={!!errors.nickname}>
<FieldLabel htmlFor="user-nickname"></FieldLabel>
<FieldContent>
<Input
id="user-nickname"
placeholder="请输入昵称"
aria-invalid={!!errors.nickname}
{...register("nickname")}
/>
<FieldError errors={[errors.nickname]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.avatar}>
<FieldLabel htmlFor="user-avatar"></FieldLabel>
<FieldContent>
<Input
id="user-avatar"
placeholder="请输入头像 URL"
aria-invalid={!!errors.avatar}
{...register("avatar")}
/>
<FieldError errors={[errors.avatar]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.mobile}>
<FieldLabel htmlFor="user-mobile"></FieldLabel>
<FieldContent>
<Input
id="user-mobile"
placeholder="请输入手机号"
aria-invalid={!!errors.mobile}
{...register("mobile")}
/>
<FieldError errors={[errors.mobile]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="user-email"></FieldLabel>
<FieldContent>
<Input
id="user-email"
placeholder="请输入邮箱"
aria-invalid={!!errors.email}
{...register("email")}
/>
<FieldError errors={[errors.email]} />
</FieldContent>
</Field>
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存修改"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
</DrawerFooter>
</form>
)}
</DrawerContent>
);
}
@@ -0,0 +1,78 @@
"use client"
import { useState } from "react"
import { CopyIcon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
type InitialPasswordDialogProps = {
open: boolean
username: string
password: string
onOpenChange: (open: boolean) => void
}
export function InitialPasswordDialog({
open,
username,
password,
onOpenChange,
}: InitialPasswordDialogProps) {
const [copying, setCopying] = useState(false)
async function handleCopy() {
if (!password || copying) {
return
}
setCopying(true)
try {
await navigator.clipboard.writeText(password)
toast.success("密码已复制")
} catch {
toast.error("复制失败,请手动复制")
} finally {
setCopying(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{username || "-"}
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border bg-muted/40 p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-2 break-all font-mono text-base">{password}</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => void handleCopy()}
disabled={copying || !password}
>
<CopyIcon />
{copying ? "复制中..." : "复制密码"}
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,104 @@
"use client"
import { useState } from "react"
import { CopyIcon } from "lucide-react"
import { toast } from "sonner"
import { type AdminUser } from "@/lib/api/admin"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
type ResetPasswordDialogsProps = {
open: boolean
saving: boolean
item: AdminUser | null
password: string
onOpenChange: (open: boolean) => void
onConfirm: () => Promise<void>
}
export function ResetPasswordDialogs({
open,
saving,
item,
password,
onOpenChange,
onConfirm,
}: ResetPasswordDialogsProps) {
const [copying, setCopying] = useState(false)
const showingResult = password.trim().length > 0
async function handleCopy() {
if (!password || copying) {
return
}
setCopying(true)
try {
await navigator.clipboard.writeText(password)
toast.success("密码已复制")
} catch {
toast.error("复制失败,请手动复制")
} finally {
setCopying(false)
}
}
return (
<>
<Dialog open={open && !showingResult} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={!saving}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{item?.username || "-"} 使
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => void onConfirm()} disabled={saving}>
{saving ? "重置中..." : "确认重置"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={open && showingResult} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{item?.username || "-"}
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border bg-muted/40 p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-2 break-all font-mono text-base">{password}</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => void handleCopy()} disabled={copying}>
<CopyIcon />
{copying ? "复制中..." : "复制密码"}
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+494
View File
@@ -0,0 +1,494 @@
"use client"
import { type KeyboardEvent, useCallback, useEffect, useState } from "react"
import {
KeyRoundIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
ShieldIcon,
UserRoundIcon,
} from "lucide-react"
import { toast } from "sonner"
import {
assignUserRoles,
createUser,
fetchRoleListAll,
fetchUserDetail,
fetchUsers,
resetUserPassword,
updateUser,
updateUserStatus,
type AdminRole,
type AdminUser,
type CreateAdminUserPayload,
type PageResult,
type ResetPasswordResult,
type UpdateAdminUserPayload,
} from "@/lib/api/admin"
import { Status } from "@/lib/generated/enums"
import { formatDateTime } from "@/lib/utils"
import { AssignRolesDrawer } from "./_components/assign-roles"
import { CreateUserDrawer } from "./_components/create"
import { EditDrawer } from "./_components/edit"
import { InitialPasswordDialog } from "./_components/initial-password-dialog"
import { ResetPasswordDialogs } from "./_components/reset-password"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { ListPagination } from "@/components/list-pagination"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export default function DashboardUsersPage() {
const [keywordInput, setKeywordInput] = useState("")
const [keyword, setKeyword] = useState("")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [creatingOpen, setCreatingOpen] = useState(false)
const [savingCreate, setSavingCreate] = useState(false)
const [initialPassword, setInitialPassword] = useState<{
username: string
password: string
} | null>(null)
const [savingEdit, setSavingEdit] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const [savingRoles, setSavingRoles] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [resettingUser, setResettingUser] = useState<AdminUser | null>(null)
const [assigningRolesUser, setAssigningRolesUser] = useState<AdminUser | null>(null)
const [assignRoleOptions, setAssignRoleOptions] = useState<AdminRole[]>([])
const [assignRoleIds, setAssignRoleIds] = useState<number[]>([])
const [assignRolesLoading, setAssignRolesLoading] = useState(false)
const [resetPasswordResult, setResetPasswordResult] =
useState<ResetPasswordResult | null>(null)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<AdminUser>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadUsers = useCallback(async () => {
setLoading(true)
try {
const data = await fetchUsers({
username: keyword.trim() || undefined,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载用户失败")
} finally {
setLoading(false)
}
}, [keyword, limit, page])
useEffect(() => {
void loadUsers()
}, [loadUsers])
function applyFilters() {
setKeyword(keywordInput)
setPage(1)
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
function openEditDrawer(user: AdminUser) {
setEditingUser(user)
}
async function openAssignRolesDrawer(user: AdminUser) {
setActionLoadingId(user.id)
setAssigningRolesUser(user)
setAssignRolesLoading(true)
try {
const [roles, userDetail] = await Promise.all([
fetchRoleListAll(),
fetchUserDetail(user.id),
])
setAssignRoleOptions(roles)
setAssignRoleIds((userDetail.roles || []).map((role) => role.id))
} catch (error) {
setAssigningRolesUser(null)
toast.error(error instanceof Error ? error.message : "加载角色分配数据失败")
} finally {
setAssignRolesLoading(false)
setActionLoadingId(null)
}
}
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)
}
function handleEditDrawerOpenChange(open: boolean) {
if (savingEdit) {
return
}
if (!open) {
setEditingUser(null)
}
}
function handleCreateDrawerOpenChange(open: boolean) {
if (savingCreate) {
return
}
if (!open) {
setCreatingOpen(false)
}
}
async function handleCreateUser(payload: CreateAdminUserPayload) {
if (savingCreate) {
return
}
setSavingCreate(true)
try {
const result = await createUser(payload)
toast.success(`已创建用户 ${result.user.username}`)
setCreatingOpen(false)
setInitialPassword({
username: result.user.username,
password: result.password,
})
await loadUsers()
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建用户失败")
} finally {
setSavingCreate(false)
}
}
function handleAssignRolesOpenChange(open: boolean) {
if (savingRoles) {
return
}
if (!open) {
setAssigningRolesUser(null)
setAssignRoleOptions([])
setAssignRoleIds([])
}
}
async function handleSaveUser(payload: UpdateAdminUserPayload) {
if (savingEdit) {
return
}
setSavingEdit(true)
try {
await updateUser(payload)
toast.success(`已更新 ${editingUser?.username || "用户"}`)
setEditingUser(null)
await loadUsers()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新用户失败")
} finally {
setSavingEdit(false)
}
}
async function handleAssignRoles(roleIds: number[]) {
if (!assigningRolesUser || savingRoles) {
return
}
setSavingRoles(true)
try {
await assignUserRoles(assigningRolesUser.id, roleIds)
toast.success(`已更新 ${assigningRolesUser.username} 的角色`)
setAssigningRolesUser(null)
setAssignRoleOptions([])
setAssignRoleIds([])
await loadUsers()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存角色分配失败")
} finally {
setSavingRoles(false)
}
}
function openResetDrawer(user: AdminUser) {
setResetPasswordResult(null)
setResettingUser(user)
}
function handleResetDrawerOpenChange(open: boolean) {
if (savingPassword) {
return
}
if (!open) {
setResetPasswordResult(null)
setResettingUser(null)
}
}
async function handleResetPassword() {
if (!resettingUser || savingPassword) {
return
}
setSavingPassword(true)
try {
const result = await resetUserPassword(resettingUser.id)
setResetPasswordResult(result)
toast.success(`已重置 ${resettingUser.username} 的密码`)
} catch (error) {
toast.error(error instanceof Error ? error.message : "重置密码失败")
} finally {
setSavingPassword(false)
}
}
async function handleToggleStatus(user: AdminUser) {
setActionLoadingId(user.id)
try {
const nextStatus = user.status === Status.Ok ? Status.Disabled : Status.Ok
await updateUserStatus(user.id, nextStatus)
toast.success(`${user.username}${nextStatus === Status.Ok ? "启用" : "禁用"}`)
await loadUsers()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
} finally {
setActionLoadingId(null)
}
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button onClick={() => setCreatingOpen(true)} disabled={loading}>
<PlusIcon />
</Button>
<div className="relative min-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="按用户名筛选"
className="pl-9"
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right"></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">
<UserRoundIcon className="size-4" />
</div>
<div>
<div className="font-medium">{item.nickname || item.username}</div>
<div className="text-xs text-muted-foreground">{item.username}</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
{(item.roles || []).length > 0 ? (
item.roles?.map((role) => (
<Badge key={role.id} variant="outline">
<ShieldIcon className="size-3" />
{role.name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
{item.status === Status.Ok ? "启用" : "禁用"}
</Badge>
{item.isSystem ? (
<Badge variant="outline" className="ml-2">
</Badge>
) : null}
</TableCell>
<TableCell>
<div className="text-sm">{formatDateTime(item.lastLoginAt)}</div>
<div className="text-xs text-muted-foreground">
{item.lastLoginIp || "-"}
</div>
</TableCell>
<TableCell>
<div className="text-sm">{item.mobile || "-"}</div>
<div className="text-xs text-muted-foreground">
{item.email || "-"}
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => openEditDrawer(item)}
disabled={actionLoadingId === item.id}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.username}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
onClick={() => void openAssignRolesDrawer(item)}
disabled={actionLoadingId === item.id}
>
<ShieldIcon />
{actionLoadingId === item.id
? "处理中..."
: "分配角色"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openResetDrawer(item)}
disabled={actionLoadingId === item.id}
>
<KeyRoundIcon />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleToggleStatus(item)}
disabled={actionLoadingId === item.id}
>
<ShieldIcon />
{actionLoadingId === item.id
? "处理中..."
: item.status === Status.Ok
? "禁用"
: "启用"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
<CreateUserDrawer
open={creatingOpen}
saving={savingCreate}
onOpenChange={handleCreateDrawerOpenChange}
onSubmit={handleCreateUser}
/>
<InitialPasswordDialog
open={!!initialPassword}
username={initialPassword?.username ?? ""}
password={initialPassword?.password ?? ""}
onOpenChange={(open) => {
if (!open) {
setInitialPassword(null)
}
}}
/>
<EditDrawer
open={!!editingUser}
saving={savingEdit}
itemId={editingUser?.id ?? null}
onOpenChange={handleEditDrawerOpenChange}
onSubmit={handleSaveUser}
/>
<ResetPasswordDialogs
open={!!resettingUser}
saving={savingPassword}
item={resettingUser}
password={resetPasswordResult?.password || ""}
onOpenChange={handleResetDrawerOpenChange}
onConfirm={handleResetPassword}
/>
<AssignRolesDrawer
open={!!assigningRolesUser}
saving={savingRoles}
loading={assignRolesLoading}
item={assigningRolesUser}
roles={assignRoleOptions}
selectedRoleIds={assignRoleIds}
onOpenChange={handleAssignRolesOpenChange}
onSubmit={handleAssignRoles}
/>
</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+129
View File
@@ -0,0 +1,129 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import { AuthProvider } from "@/components/auth-provider"
import { ConfirmProvider } from "@/components/confirm-provider"
import { ImageLightboxProvider } from "@/components/image-lightbox"
import { ThemeProvider } from "@/components/theme-provider"
import { TooltipProvider } from "@/components/ui/tooltip"
import { Toaster } from "@/components/ui/sonner"
import "./globals.css"
import "md-editor-rt/lib/style.css"
import "@/styles/main.scss"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
})
export const metadata: Metadata = {
title: "AI 客服后台管理系统",
description: "AI 客服后台管理系统",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider>
<AuthProvider>
<ConfirmProvider>
<ImageLightboxProvider>
<TooltipProvider>
{children}
<Toaster position="top-center" richColors />
</TooltipProvider>
</ImageLightboxProvider>
</ConfirmProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { LoginForm } from "@/components/login-form"
import { BotMessageSquareIcon, ShieldCheckIcon, UsersIcon, KeyRoundIcon } from "lucide-react"
import Link from "next/link"
import { Suspense } from "react"
export default function LoginPage() {
return (
<div className="grid min-h-svh bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] lg:grid-cols-[1.1fr_0.9fr]">
<div className="relative hidden overflow-hidden border-r bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_30%),radial-gradient(circle_at_bottom_right,rgba(6,182,212,0.18),transparent_28%),linear-gradient(145deg,#111827_0%,#1f2937_35%,#0f172a_100%)] lg:block">
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),transparent_22%)]" />
<div className="relative flex h-full flex-col justify-between p-10 text-white">
<div className="space-y-5">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-3 py-1 text-xs tracking-[0.2em] uppercase text-white/80">
<ShieldCheckIcon className="size-3.5" />
Secure Admin Space
</div>
<div className="space-y-3">
<h2 className="max-w-lg text-5xl font-semibold tracking-tight">
</h2>
<p className="max-w-xl text-sm leading-6 text-white/72">
</p>
</div>
</div>
<div className="grid gap-3">
{[
{
text: "让每一次咨询,都通向可追踪的结果。",
},
{
text: "从接待到转派到复盘,一条链路跑到底。",
},
{
text: "多渠道接入,智能分配,人机协同提效。",
},
].map((item, index) => (
<div
key={index}
className="rounded-2xl border border-white/10 bg-white/7 p-4 backdrop-blur"
>
<p className="text-sm leading-6 text-white/70">{item.text}</p>
</div>
))}
</div>
</div>
</div>
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/login" className="flex items-center gap-2 font-medium">
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
<BotMessageSquareIcon className="size-4" />
</div>
CS Agent
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<Suspense fallback={<div className="min-h-80" />}>
<LoginForm />
</Suspense>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,65 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useRef } from "react"
import { toast } from "sonner"
import { exchangeWxWorkTicket } from "@/lib/api/auth"
export default function WxWorkLoginCallbackPage() {
return (
<Suspense fallback={<WxWorkLoginCallbackFallback />}>
<WxWorkLoginCallbackContent />
</Suspense>
)
}
function WxWorkLoginCallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const ranRef = useRef(false)
useEffect(() => {
if (ranRef.current) {
return
}
ranRef.current = true
const ticket = searchParams.get("ticket")?.trim() ?? ""
const next = searchParams.get("next")
const nextPath = next && next.startsWith("/") ? next : "/"
if (!ticket) {
toast.error("企业微信登录票据不存在")
router.replace("/login")
return
}
void exchangeWxWorkTicket(ticket)
.then(() => {
toast.success("登录成功,正在进入系统")
router.replace(nextPath)
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "企业微信登录失败")
router.replace("/login")
})
}, [router, searchParams])
return (
<WxWorkLoginCallbackFallback />
)
}
function WxWorkLoginCallbackFallback() {
return (
<div className="flex min-h-svh items-center justify-center bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] px-6">
<div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 text-center shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-3 text-sm text-muted-foreground">
</p>
</div>
</div>
)
}