update web to dashboard
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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-0;lg 起有 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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、answer;similarQuestions 使用 | 或换行分隔。"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+69
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user