refactor: support i18n

This commit is contained in:
mlogclub
2026-05-25 12:06:15 +08:00
parent 309ac1fe9e
commit 988f55c80d
179 changed files with 10968 additions and 3763 deletions
+7 -4
View File
@@ -5,6 +5,7 @@ import { ArrowRightIcon, ShieldAlertIcon } from "lucide-react"
import type { DashboardAlert } from "@/lib/api/dashboard"
import { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import {
Card,
CardContent,
@@ -28,19 +29,21 @@ function getAlertBadgeVariant(level: DashboardAlert["level"]) {
}
export function AlertList({ alerts }: AlertListProps) {
const t = useI18n()
return (
<Card>
<CardHeader>
<CardTitle>风险提醒</CardTitle>
<CardDescription>优先处理会直接影响接待效率和 AI 稳定性的项目</CardDescription>
<CardTitle>{t("dashboardHome.riskAlerts")}</CardTitle>
<CardDescription>{t("dashboardHome.riskAlertsDescription")}</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="text-sm font-medium">{t("dashboardHome.noRiskTitle")}</div>
<div className="mt-1 text-sm text-muted-foreground">
首页将持续监控会话堆积、客服排班与 AI 配置异常
{t("dashboardHome.noRiskDescription")}
</div>
</div>
) : (
@@ -7,6 +7,7 @@ import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent } from "@/components/ui/card"
import { useI18n } from "@/i18n/provider"
import {
fetchDashboardOverview,
type DashboardOverview,
@@ -17,12 +18,6 @@ import { TrendPanel } from "./trend-panel"
import { TeamLoadPanel } from "./team-load-panel"
import { AlertList } from "./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">
@@ -40,6 +35,7 @@ function LoadingCards() {
}
export function DashboardHome() {
const t = useI18n()
const [range, setRange] = useState<DashboardRange>("7d")
const [data, setData] = useState<DashboardOverview | null>(null)
const [loading, setLoading] = useState(true)
@@ -56,27 +52,33 @@ export function DashboardHome() {
const result = await fetchDashboardOverview(nextRange)
setData(result)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载首页概览失败")
toast.error(error instanceof Error ? error.message : t("dashboardHome.loadFailed"))
} finally {
setLoading(false)
setRefreshing(false)
}
},
[]
[t]
)
useEffect(() => {
void loadData(range)
}, [loadData, range])
const rangeOptions: Array<{ value: DashboardRange; label: string }> = [
{ value: "today", label: t("dashboardHome.rangeToday") },
{ value: "7d", label: t("dashboardHome.range7d") },
{ value: "30d", label: t("dashboardHome.range30d") },
]
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("dashboardHome.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">
聚焦会话接待、工单处理、客服负载与 AI 运行状态
{data ? `,更新于 ${data.generatedAt}` : ""}
{t("dashboardHome.description")}
{data ? t("dashboardHome.updatedAt", { time: data.generatedAt }) : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -99,7 +101,7 @@ export function DashboardHome() {
disabled={loading || refreshing}
>
<RefreshCwIcon className={refreshing ? "mr-2 size-4 animate-spin" : "mr-2 size-4"} />
刷新
{t("dashboardHome.refresh")}
</Button>
</div>
</div>
@@ -111,8 +113,8 @@ export function DashboardHome() {
<SummaryCards summary={data.summary} />
<TrendPanel
title="会话趋势"
description="观察新增与关闭会话变化,快速判断接待压力是否持续上升"
title={t("dashboardHome.conversationTrend")}
description={t("dashboardHome.conversationTrendDescription")}
trend={data.conversationStats.trend}
distribution={data.conversationStats.statusDistribution}
/>
@@ -123,33 +125,33 @@ export function DashboardHome() {
<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="text-sm text-muted-foreground">{t("dashboardHome.enabledAiAgents")}</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="text-sm text-muted-foreground">{t("dashboardHome.enabledChannels")}</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="text-sm text-muted-foreground">{t("dashboardHome.todayKnowledgeRetrieves")}</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="text-sm text-muted-foreground">{t("dashboardHome.todayKnowledgeRetrieveFailRate")}</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="text-sm text-muted-foreground">{t("dashboardHome.todaySkillRunFailCount")}</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="text-sm text-muted-foreground">{t("dashboardHome.todayAiHandoffCount")}</div>
<div className="mt-2 text-3xl font-semibold">
{data.aiStats.todayAiHandoffCount}
</div>
@@ -163,7 +165,7 @@ export function DashboardHome() {
) : (
<Card>
<CardContent className="flex min-h-60 items-center justify-center p-6 text-sm text-muted-foreground">
暂无首页概览数据
{t("dashboardHome.empty")}
</CardContent>
</Card>
)}
+17 -14
View File
@@ -17,6 +17,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { useI18n } from "@/i18n/provider"
type SummaryCardsProps = {
summary: DashboardOverview["summary"]
@@ -24,8 +25,8 @@ type SummaryCardsProps = {
type SummaryCardItem = {
key: keyof DashboardOverview["summary"]
title: string
description: string
titleKey: string
descriptionKey: string
link: string
icon: typeof BotMessageSquareIcon
format?: (value: number) => string
@@ -34,36 +35,36 @@ type SummaryCardItem = {
const cards: SummaryCardItem[] = [
{
key: "todayNewConversations",
title: "今日新增会话",
description: "今日进入系统的新增咨询量",
titleKey: "dashboardHome.summaryTodayNewConversations",
descriptionKey: "dashboardHome.summaryTodayNewConversationsDescription",
link: "/dashboard/conversations",
icon: BotMessageSquareIcon,
},
{
key: "processingConversations",
title: "当前处理中",
description: "正在由 AI 或人工接待的会话",
titleKey: "dashboardHome.summaryProcessingConversations",
descriptionKey: "dashboardHome.summaryProcessingConversationsDescription",
link: "/dashboard/conversations",
icon: WavesIcon,
},
{
key: "pendingDispatchConversations",
title: "待分配会话",
description: "仍在待接入池中等待分配",
titleKey: "dashboardHome.summaryPendingDispatchConversations",
descriptionKey: "dashboardHome.summaryPendingDispatchConversationsDescription",
link: "/dashboard/conversations",
icon: CircleDashedIcon,
},
{
key: "onlineAgents",
title: "在线客服",
description: "近 15 分钟内仍有活跃心跳的客服",
titleKey: "dashboardHome.summaryOnlineAgents",
descriptionKey: "dashboardHome.summaryOnlineAgentsDescription",
link: "/dashboard/agents",
icon: HeadsetIcon,
},
{
key: "aiServiceRate",
title: "AI 接待占比",
description: "当前活跃会话中 AI 参与服务比例",
titleKey: "dashboardHome.summaryAiServiceRate",
descriptionKey: "dashboardHome.summaryAiServiceRateDescription",
link: "/dashboard/ai-agents",
icon: SparklesIcon,
format: (value: number) => `${value.toFixed(1)}%`,
@@ -71,6 +72,8 @@ const cards: SummaryCardItem[] = [
]
export function SummaryCards({ summary }: SummaryCardsProps) {
const t = useI18n()
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{cards.map((item) => {
@@ -86,8 +89,8 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
<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>
<CardTitle className="text-sm font-medium">{t(item.titleKey)}</CardTitle>
<CardDescription>{t(item.descriptionKey)}</CardDescription>
</div>
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Icon className="size-4" />
@@ -1,6 +1,7 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import {
Card,
CardContent,
@@ -25,18 +26,24 @@ function getLoadTone(loadRate: number) {
}
export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
const t = useI18n()
return (
<Card>
<CardHeader>
<CardTitle>客服组负载</CardTitle>
<CardTitle>{t("dashboardHome.teamLoad")}</CardTitle>
<CardDescription>
在线 {agentStats.onlineAgents},忙碌 {agentStats.busyAgents},离线 {agentStats.offlineAgents}
{t("dashboardHome.teamLoadDescription", {
online: agentStats.onlineAgents,
busy: agentStats.busyAgents,
offline: 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">
暂无客服组数据
{t("dashboardHome.noTeamData")}
</div>
) : (
agentStats.teamLoads.map((item) => (
@@ -46,20 +53,27 @@ export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
<div className="flex items-center gap-2">
<div className="font-medium">{item.teamName}</div>
{item.hasScheduleNow ? (
<Badge variant="secondary">排班中</Badge>
<Badge variant="secondary">{t("dashboardHome.scheduled")}</Badge>
) : (
<Badge variant="outline">无当前排班</Badge>
<Badge variant="outline">{t("dashboardHome.notScheduled")}</Badge>
)}
</div>
<div className="mt-1 text-sm text-muted-foreground">
总客服 {item.totalAgents},在线 {item.onlineAgents},忙碌 {item.busyAgents},离线{" "}
{item.offlineAgents}
{t("dashboardHome.teamStats", {
total: item.totalAgents,
online: item.onlineAgents,
busy: item.busyAgents,
offline: 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}
{t("dashboardHome.load", {
processing: item.processingConversations,
capacity: item.maxConcurrentCapacity || 0,
})}
</div>
</div>
</div>
@@ -73,19 +87,19 @@ export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
<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="text-muted-foreground">{t("dashboardHome.waiting")}</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="text-muted-foreground">{t("dashboardHome.processing")}</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="text-muted-foreground">{t("dashboardHome.capacity")}</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="text-muted-foreground">{t("dashboardHome.busyAgents")}</div>
<div className="mt-1 text-lg font-semibold">{item.busyAgents}</div>
</div>
</div>
+48 -24
View File
@@ -16,6 +16,7 @@ import {
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { useI18n } from "@/i18n/provider"
type TrendPanelProps = {
title: string
@@ -24,33 +25,37 @@ type TrendPanelProps = {
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) {
const t = useI18n()
const trendConfig = {
newCount: {
label: t("dashboardHome.chartNew"),
color: "hsl(24 95% 53%)",
},
closedCount: {
label: t("dashboardHome.chartClosed"),
color: "hsl(190 95% 39%)",
},
} satisfies ChartConfig
const distributionConfig = {
count: {
label: t("dashboardHome.chartCount"),
theme: {
light: "hsl(222 47% 11%)",
dark: "hsl(210 40% 98%)",
},
},
} satisfies ChartConfig
const localizedDistribution = distribution.map((item) => ({
...item,
label: getStatusLabel(item.status, item.label, t),
}))
return (
<div className="grid gap-4 xl:grid-cols-[1.5fr_0.9fr]">
<Card>
@@ -87,12 +92,12 @@ export function TrendPanel({
<Card>
<CardHeader>
<CardTitle>状态分布</CardTitle>
<CardDescription>当前状态数量分布</CardDescription>
<CardTitle>{t("dashboardHome.statusDistribution")}</CardTitle>
<CardDescription>{t("dashboardHome.statusDistributionDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ChartContainer config={distributionConfig} className="h-72 w-full">
<BarChart data={distribution} layout="vertical" margin={{ left: 20 }}>
<BarChart data={localizedDistribution} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid horizontal={false} />
<YAxis
type="category"
@@ -115,3 +120,22 @@ export function TrendPanel({
</div>
)
}
function getStatusLabel(
status: number,
fallback: string,
t: (key: string) => string
) {
switch (status) {
case 1:
return t("dashboardHome.statusAiServing")
case 2:
return t("dashboardHome.statusPending")
case 3:
return t("dashboardHome.statusActive")
case 4:
return t("dashboardHome.statusClosed")
default:
return fallback
}
}
@@ -9,6 +9,7 @@ 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 { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
type AgentRunLogDetailDialogProps = {
@@ -17,11 +18,14 @@ type AgentRunLogDetailDialogProps = {
onOpenChange: (open: boolean) => void
}
type TFunction = (key: string, values?: Record<string, string | number>) => string
export function AgentRunLogDetailDialog({
open,
logId,
onOpenChange,
}: AgentRunLogDetailDialogProps) {
const t = useI18n()
const [loading, setLoading] = useState(false)
const [activeLog, setActiveLog] = useState<AgentRunLog | null>(null)
@@ -42,7 +46,7 @@ export function AgentRunLogDetailDialog({
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载日志详情失败")
toast.error(error instanceof Error ? error.message : t("agentRunLog.loadDetailFailed"))
onOpenChange(false)
}
} finally {
@@ -57,7 +61,7 @@ export function AgentRunLogDetailDialog({
return () => {
cancelled = true
}
}, [logId, onOpenChange, open])
}, [logId, onOpenChange, open, t])
useEffect(() => {
if (open) {
@@ -87,35 +91,35 @@ export function AgentRunLogDetailDialog({
title={
<span className="flex items-center gap-2">
<WorkflowIcon className="size-4" />
Agent 运行详情
{t("agentRunLog.detailTitle")}
</span>
}
description="查看 planner 选择、最终动作、回复内容与错误信息。"
description={t("agentRunLog.detailDescription")}
size="xl"
allowFullscreen
defaultFullscreen
bodyClassName="min-h-0"
footer={
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
{t("agentRunLog.close")}
</Button>
}
>
{loading ? (
<div className="py-10 text-sm text-muted-foreground">加载中...</div>
<div className="py-10 text-sm text-muted-foreground">{t("agentRunLog.loading")}</div>
) : activeLog ? (
<>
<MetaStrip
items={[
{ label: "日志ID", value: String(activeLog.id) },
{ label: "会话ID", value: String(activeLog.conversationId || "-") },
{ label: "消息ID", value: String(activeLog.messageId || "-") },
{ label: t("agentRunLog.logId"), value: String(activeLog.id) },
{ label: t("agentRunLog.conversationId"), value: String(activeLog.conversationId || "-") },
{ label: t("agentRunLog.messageId"), value: String(activeLog.messageId || "-") },
{ label: "AI Agent", value: String(activeLog.aiAgentId || "-") },
]}
/>
<InfoBlock
title="规划阶段"
title={t("agentRunLog.planningStage")}
lines={[
`plannedAction: ${activeLog.plannedAction || "-"}`,
`plannedSkillCode: ${activeLog.plannedSkillCode || "-"}`,
@@ -131,15 +135,15 @@ export function AgentRunLogDetailDialog({
]}
/>
<InfoBlock
title="HITL 状态"
title={t("agentRunLog.hitlStatus")}
lines={[
`hitlStatus: ${activeLog.hitlStatus || "-"}`,
`hitlStatusName: ${activeLog.hitlStatusName || "-"}`,
`hitlSummary: ${activeLog.hitlSummary || "-"}`,
`hitlStatusName: ${getHitlStatusLabel(activeLog.hitlStatus, t) || "-"}`,
`hitlSummary: ${getHitlSummary(activeLog.hitlStatus, t) || "-"}`,
]}
/>
<InfoBlock
title="执行结果"
title={t("agentRunLog.executionResult")}
lines={[
`finalAction: ${activeLog.finalAction || "-"}`,
`finalStatus: ${activeLog.finalStatus || "-"}`,
@@ -151,40 +155,74 @@ export function AgentRunLogDetailDialog({
/>
<JsonBlock
title="动态工具选择"
title={t("agentRunLog.dynamicTools")}
jsonValue={activeToolSearchTrace}
fallbackValue={activeLog.toolSearchTrace}
/>
<JsonBlock
title="Graph Tool 调用"
title={t("agentRunLog.graphToolCall")}
jsonValue={activeGraphToolTrace}
fallbackValue={activeLog.graphToolTrace}
/>
<TextBlock
icon={<BotMessageSquareIcon className="size-4" />}
title="用户问题"
title={t("agentRunLog.userMessage")}
value={activeLog.userMessage}
renderAsHtml
/>
<TextBlock
icon={<WorkflowIcon className="size-4" />}
title="机器人回复"
title={t("agentRunLog.botReply")}
value={activeLog.replyText}
/>
<TextBlock title="错误信息" value={activeLog.errorMessage} tone="danger" />
<TextBlock title={t("agentRunLog.errorMessage")} value={activeLog.errorMessage} tone="danger" />
<JsonBlock
title="链路 Trace"
title={t("agentRunLog.trace")}
jsonValue={activeTraceData}
fallbackValue={activeLog.traceData}
/>
</>
) : (
<div className="py-10 text-sm text-muted-foreground">未找到详情数据</div>
<div className="py-10 text-sm text-muted-foreground">{t("agentRunLog.notFound")}</div>
)}
</ProjectDialog>
)
}
function getHitlStatusLabel(status: string | undefined, t: TFunction) {
switch (status) {
case "pending":
return t("agentRunLog.hitlPending")
case "confirmed":
return t("agentRunLog.hitlConfirmed")
case "cancelled":
return t("agentRunLog.hitlCancelled")
case "expired":
return t("agentRunLog.hitlExpired")
case "triggered":
return t("agentRunLog.hitlTriggered")
default:
return ""
}
}
function getHitlSummary(status: string | undefined, t: TFunction) {
switch (status) {
case "pending":
return t("agentRunLog.hitlPendingSummary")
case "confirmed":
return t("agentRunLog.hitlConfirmedSummary")
case "cancelled":
return t("agentRunLog.hitlCancelledSummary")
case "expired":
return t("agentRunLog.hitlExpiredSummary")
case "triggered":
return t("agentRunLog.hitlTriggeredSummary")
default:
return ""
}
}
function safeParseJSON(value: string) {
if (!value.trim()) {
return null
+100 -70
View File
@@ -22,36 +22,62 @@ import {
type AgentRunLog,
type PageResult,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
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: "兜底" },
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
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" },
]
function getActionOptions(t: TFunction) {
return [
{ value: "all", label: t("agentRunLog.allActions") },
{ value: "rag", label: "RAG" },
{ value: "skill", label: "Skill" },
{ value: "tool", label: "Tool" },
{ value: "graph", label: "Graph" },
{ value: "handoff", label: t("agentRunLog.handoff") },
{ value: "reply", label: t("agentRunLog.reply") },
{ value: "fallback", label: t("agentRunLog.fallback") },
]
}
const hitlStatusOptions = [
{ value: "all", label: "全部 HITL" },
{ value: "pending", label: "等待确认" },
{ value: "confirmed", label: "已确认" },
{ value: "cancelled", label: "已取消" },
{ value: "expired", label: "已过期" },
{ value: "triggered", label: "已触发" },
]
function getFinalStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("agentRunLog.allStatus") },
{ value: "completed", label: "completed" },
{ value: "interrupted", label: "interrupted" },
{ value: "expired", label: "expired" },
{ value: "error", label: "error" },
{ value: "fallback", label: "fallback" },
]
}
function getHitlStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("agentRunLog.allHitl") },
{ value: "pending", label: t("agentRunLog.hitlPending") },
{ value: "confirmed", label: t("agentRunLog.hitlConfirmed") },
{ value: "cancelled", label: t("agentRunLog.hitlCancelled") },
{ value: "expired", label: t("agentRunLog.hitlExpired") },
{ value: "triggered", label: t("agentRunLog.hitlTriggered") },
]
}
function getHitlStatusLabel(status: string | undefined, t: TFunction) {
switch (status) {
case "pending":
return t("agentRunLog.hitlPending")
case "confirmed":
return t("agentRunLog.hitlConfirmed")
case "cancelled":
return t("agentRunLog.hitlCancelled")
case "expired":
return t("agentRunLog.hitlExpired")
case "triggered":
return t("agentRunLog.hitlTriggered")
default:
return ""
}
}
function actionBadgeVariant(action: string) {
switch (action) {
@@ -73,6 +99,7 @@ function actionBadgeVariant(action: string) {
}
export default function DashboardAgentRunLogsPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("")
const [plannedActionInput, setPlannedActionInput] = useState("all")
const [finalActionInput, setFinalActionInput] = useState("all")
@@ -95,16 +122,19 @@ export default function DashboardAgentRunLogsPage() {
page: { page: 1, limit: 20, total: 0 },
})
const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
const actionOptions = useMemo(() => getActionOptions(t), [t])
const finalStatusOptions = useMemo(() => getFinalStatusOptions(t), [t])
const hitlStatusOptions = useMemo(() => getHitlStatusOptions(t), [t])
const aiAgentOptions = useMemo(
() => [
{ value: "all", label: "全部 Agent" },
{ value: "all", label: t("agentRunLog.allAgents") },
...aiAgents.map((item) => ({
value: String(item.id),
label: item.name,
})),
],
[aiAgents]
[aiAgents, t]
)
const loadData = useCallback(async () => {
@@ -122,11 +152,11 @@ export default function DashboardAgentRunLogsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Agent 运行日志失败")
toast.error(error instanceof Error ? error.message : t("agentRunLog.loadFailed"))
} finally {
setLoading(false)
}
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction])
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction, t])
useEffect(() => {
void loadData()
@@ -138,11 +168,11 @@ export default function DashboardAgentRunLogsPage() {
const data = await fetchAIAgentsAll()
setAiAgents(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI Agent 列表失败")
toast.error(error instanceof Error ? error.message : t("agentRunLog.loadAgentsFailed"))
}
}
void loadAIAgents()
}, [])
}, [t])
function applyFilters() {
setKeyword(keywordInput)
@@ -174,7 +204,7 @@ export default function DashboardAgentRunLogsPage() {
className="w-full xl:w-auto"
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
刷新
{t("agentRunLog.refresh")}
</Button>
}
>
@@ -184,7 +214,7 @@ export default function DashboardAgentRunLogsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按用户问题筛选"
placeholder={t("agentRunLog.filterUserMessage")}
className="pl-9"
/>
</div>
@@ -192,9 +222,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={plannedActionInput}
options={actionOptions}
placeholder="规划动作"
searchPlaceholder="搜索动作"
emptyText="未找到动作"
placeholder={t("agentRunLog.plannedAction")}
searchPlaceholder={t("agentRunLog.searchAction")}
emptyText={t("agentRunLog.emptyAction")}
onChange={(value) => setPlannedActionInput(value || "all")}
/>
</div>
@@ -202,9 +232,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={finalActionInput}
options={actionOptions}
placeholder="最终动作"
searchPlaceholder="搜索动作"
emptyText="未找到动作"
placeholder={t("agentRunLog.finalAction")}
searchPlaceholder={t("agentRunLog.searchAction")}
emptyText={t("agentRunLog.emptyAction")}
onChange={(value) => setFinalActionInput(value || "all")}
/>
</div>
@@ -212,9 +242,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={finalStatusInput}
options={finalStatusOptions}
placeholder="最终状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("agentRunLog.finalStatus")}
searchPlaceholder={t("agentRunLog.searchStatus")}
emptyText={t("agentRunLog.emptyStatus")}
onChange={(value) => setFinalStatusInput(value || "all")}
/>
</div>
@@ -222,9 +252,9 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={hitlStatusInput}
options={hitlStatusOptions}
placeholder="HITL 状态"
searchPlaceholder="搜索 HITL 状态"
emptyText="未找到状态"
placeholder={t("agentRunLog.hitlStatus")}
searchPlaceholder={t("agentRunLog.searchHitl")}
emptyText={t("agentRunLog.emptyStatus")}
onChange={(value) => setHitlStatusInput(value || "all")}
/>
</div>
@@ -232,15 +262,15 @@ export default function DashboardAgentRunLogsPage() {
<OptionCombobox
value={aiAgentIdInput}
options={aiAgentOptions}
placeholder="选择 Agent"
searchPlaceholder="搜索 Agent"
emptyText="未找到 Agent"
placeholder={t("agentRunLog.selectAgent")}
searchPlaceholder={t("agentRunLog.searchAgent")}
emptyText={t("agentRunLog.emptyAgent")}
onChange={(value) => setAiAgentIdInput(value || "all")}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading} className="w-full xl:w-auto">
<SearchIcon />
查询
{t("agentRunLog.query")}
</Button>
</DashboardToolbar>
@@ -261,18 +291,18 @@ export default function DashboardAgentRunLogsPage() {
>
{!loading && result.results.length === 0 ? (
<div className="py-14 text-center text-sm text-muted-foreground">
暂无 Agent 运行日志
{t("agentRunLog.emptyRows")}
</div>
) : (
<div>
<div className="hidden grid-cols-[160px_minmax(0,1.8fr)_110px_minmax(0,1.2fr)_130px_90px_76px] gap-3 border-b bg-muted/40 px-4 py-3 text-sm text-muted-foreground lg:grid">
<div>时间</div>
<div>用户问题</div>
<div>规划动作</div>
<div>Skill / Tool</div>
<div>最终状态</div>
<div className="text-right">耗时</div>
<div className="text-right">操作</div>
<div>{t("agentRunLog.time")}</div>
<div>{t("agentRunLog.userMessage")}</div>
<div>{t("agentRunLog.plannedAction")}</div>
<div>{t("agentRunLog.skillTool")}</div>
<div>{t("agentRunLog.finalStatus")}</div>
<div className="text-right">{t("agentRunLog.duration")}</div>
<div className="text-right">{t("agentRunLog.actions")}</div>
</div>
<div className="divide-y">
@@ -286,7 +316,7 @@ export default function DashboardAgentRunLogsPage() {
</div>
<div className="min-w-0">
<UserMessagePreview value={item.userMessage} />
<UserMessagePreview value={item.userMessage} t={t} />
{item.errorMessage ? (
<div className="truncate text-xs text-destructive">{item.errorMessage}</div>
) : null}
@@ -310,13 +340,13 @@ export default function DashboardAgentRunLogsPage() {
</div>
) : item.handoffReason ? (
<div className="truncate text-xs text-muted-foreground">
转人工原因:{item.handoffReason}
{t("agentRunLog.handoffReason", { reason: item.handoffReason })}
</div>
) : item.recommendedAction ? (
<div className="truncate text-xs text-muted-foreground">
分流建议:{item.recommendedAction}
{t("agentRunLog.routingRecommendation", { action: item.recommendedAction })}
{item.riskLevel ? ` / ${item.riskLevel} risk` : ""}
{item.ticketDraftReady ? " / 草稿已就绪" : ""}
{item.ticketDraftReady ? ` / ${t("agentRunLog.draftReady")}` : ""}
</div>
) : null}
</div>
@@ -331,8 +361,8 @@ export default function DashboardAgentRunLogsPage() {
{item.finalAction || "-"}
</Badge>
<div className="truncate text-xs text-muted-foreground">
{item.hitlStatusName
? `${item.hitlStatusName} / ${item.finalStatus || "-"}`
{getHitlStatusLabel(item.hitlStatus, t)
? `${getHitlStatusLabel(item.hitlStatus, t)} / ${item.finalStatus || "-"}`
: item.finalStatus || "-"}
</div>
</div>
@@ -351,7 +381,7 @@ export default function DashboardAgentRunLogsPage() {
setDetailOpen(true)
}}
>
详情
{t("agentRunLog.detail")}
</Button>
</div>
</article>
@@ -375,8 +405,8 @@ export default function DashboardAgentRunLogsPage() {
)
}
function UserMessagePreview({ value }: { value?: string }) {
const preview = useMemo(() => summarizeUserMessage(value), [value])
function UserMessagePreview({ value, t }: { value?: string; t: TFunction }) {
const preview = useMemo(() => summarizeUserMessage(value, t), [value, t])
return (
<div className="truncate text-sm text-foreground">
@@ -385,7 +415,7 @@ function UserMessagePreview({ value }: { value?: string }) {
)
}
function summarizeUserMessage(value?: string) {
function summarizeUserMessage(value: string | undefined, t: TFunction) {
const normalized = value?.trim()
if (!normalized) {
return "-"
@@ -396,9 +426,9 @@ function summarizeUserMessage(value?: string) {
}
if (containsHTML(normalized)) {
if (/<img[\s>]/i.test(normalized)) {
return "[图片]"
return t("agentRunLog.imageMessage")
}
return "[富文本消息]"
return t("agentRunLog.richMessage")
}
return normalized
}
@@ -34,6 +34,7 @@ import {
type AdminAgentTeamScheduleBatchPreview,
type BatchAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
type BatchScheduleDialogProps = {
@@ -42,15 +43,24 @@ type BatchScheduleDialogProps = {
onSuccess: (created: number) => void | Promise<void>
}
const weekdayOptions = [
{ value: 1, label: "周一" },
{ value: 2, label: "周二" },
{ value: 3, label: "周三" },
{ value: 4, label: "周四" },
{ value: 5, label: "周五" },
{ value: 6, label: "周六" },
{ value: 7, label: "周日" },
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
const weekdayKeys = [
"weekdayMon",
"weekdayTue",
"weekdayWed",
"weekdayThu",
"weekdayFri",
"weekdaySat",
"weekdaySun",
] as const
function getWeekdayOptions(t: TFunction) {
return weekdayKeys.map((key, index) => ({
value: index + 1,
label: t(`agentTeamSchedule.${key}`),
}))
}
function todayDateValue() {
const today = new Date()
@@ -88,32 +98,33 @@ function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload
}
}
function getWeekdayLabel(value: number) {
return weekdayOptions.find((option) => option.value === value)?.label ?? `周${value}`
function getWeekdayLabel(value: number, t: TFunction) {
const weekdayOptions = getWeekdayOptions(t)
return weekdayOptions.find((option) => option.value === value)?.label ?? String(value)
}
function validateForm(form: BatchFormState) {
function validateForm(form: BatchFormState, t: TFunction) {
const today = todayDateValue()
if (form.selectedTeamIds.length === 0) {
return "请选择至少一个客服组"
return t("agentTeamSchedule.selectAtLeastOneTeam")
}
if (!form.startDate || !form.endDate) {
return "请选择日期范围"
return t("agentTeamSchedule.selectDateRange")
}
if (form.startDate < today) {
return "开始日期不能早于今天"
return t("agentTeamSchedule.startBeforeToday")
}
if (form.endDate < form.startDate) {
return "结束日期不能早于开始日期"
return t("agentTeamSchedule.endBeforeStart")
}
if (form.weekdays.length === 0) {
return "请选择至少一个星期"
return t("agentTeamSchedule.selectAtLeastOneWeekday")
}
if (!form.startTime || !form.endTime) {
return "请选择开始和结束时间"
return t("agentTeamSchedule.selectStartEndTime")
}
if (form.endTime <= form.startTime) {
return "结束时间必须晚于开始时间"
return t("agentTeamSchedule.endAfterStart")
}
return ""
}
@@ -123,6 +134,7 @@ export function BatchScheduleDialog({
onOpenChange,
onSuccess,
}: BatchScheduleDialogProps) {
const t = useI18n()
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [form, setForm] = useState(defaultFormState)
const [step, setStep] = useState<DialogStep>("form")
@@ -134,6 +146,7 @@ export function BatchScheduleDialog({
const openRef = useRef(open)
const previewRequestIdRef = useRef(0)
const busy = loadingTeams || previewing || submitting
const weekdayOptions = useMemo(() => getWeekdayOptions(t), [t])
const teamOptions = useMemo(
() =>
@@ -181,7 +194,7 @@ export function BatchScheduleDialog({
}
} catch (error) {
if (!ignore) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
}
} finally {
if (!ignore) {
@@ -194,7 +207,7 @@ export function BatchScheduleDialog({
return () => {
ignore = true
}
}, [open])
}, [open, t])
function updateForm(values: Partial<BatchFormState>) {
previewRequestIdRef.current += 1
@@ -234,7 +247,7 @@ export function BatchScheduleDialog({
}
async function handlePreview() {
const validationMessage = validateForm(form)
const validationMessage = validateForm(form, t)
if (validationMessage) {
toast.error(validationMessage)
return
@@ -254,7 +267,7 @@ export function BatchScheduleDialog({
setStep("preview")
} catch (error) {
if (openRef.current && previewRequestIdRef.current === requestId) {
toast.error(error instanceof Error ? error.message : "预览批量排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.previewFailed"))
}
} finally {
if (openRef.current && previewRequestIdRef.current === requestId) {
@@ -265,11 +278,11 @@ export function BatchScheduleDialog({
async function handleSubmit() {
if (!preview || !previewPayload) {
toast.error("请先预览批量排班")
toast.error(t("agentTeamSchedule.previewFirst"))
return
}
if (preview.conflict) {
toast.error("存在冲突排班,不能提交")
toast.error(t("agentTeamSchedule.conflictCannotSubmit"))
return
}
@@ -277,17 +290,17 @@ export function BatchScheduleDialog({
setSubmitting(true)
try {
const data = await generateAgentTeamScheduleBatch(payload)
toast.success(`已创建 ${data.created} 条客服组排班`)
toast.success(t("agentTeamSchedule.batchCreated", { count: data.created }))
setPreview(null)
setPreviewPayload(null)
onOpenChange(false)
try {
await onSuccess(data.created)
} catch (error) {
toast.error(error instanceof Error ? `排班已生成,但刷新列表失败:${error.message}` : "排班已生成,但刷新列表失败")
toast.error(error instanceof Error ? t("agentTeamSchedule.batchRefreshFailed", { message: error.message }) : t("agentTeamSchedule.batchRefreshFailedFallback"))
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成批量排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.generateFailed"))
} finally {
setSubmitting(false)
}
@@ -297,9 +310,9 @@ export function BatchScheduleDialog({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle>批量排班</DialogTitle>
<DialogTitle>{t("agentTeamSchedule.batch")}</DialogTitle>
<DialogDescription>
选择客服组、日期范围和工作时间,先预览后生成排班。
{t("agentTeamSchedule.batchDescription")}
</DialogDescription>
</DialogHeader>
@@ -307,15 +320,15 @@ export function BatchScheduleDialog({
{step === "form" ? (
<div className="space-y-5">
<div className="space-y-2">
<Label>客服组</Label>
<Label>{t("agentTeamSchedule.team")}</Label>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<OptionCombobox
value=""
options={teamOptions}
placeholder={loadingTeams ? "加载客服组中..." : "添加客服组"}
searchPlaceholder="搜索客服组"
emptyText={teams.length === 0 ? "暂无客服组" : "已选择全部客服组"}
placeholder={loadingTeams ? t("agentTeamSchedule.loadingTeams") : t("agentTeamSchedule.addTeam")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={teams.length === 0 ? t("agentTeamSchedule.noTeams") : t("agentTeamSchedule.allTeamsSelected")}
disabled={loadingTeams}
onChange={handleTeamSelect}
/>
@@ -332,7 +345,7 @@ export function BatchScheduleDialog({
size="icon-sm"
className="size-5 rounded-sm"
onClick={() => removeTeam(team.id)}
aria-label={`移除${team.name}`}
aria-label={t("agentTeamSchedule.removeTeam", { name: team.name })}
>
<XIcon className="size-3" />
</Button>
@@ -340,13 +353,13 @@ export function BatchScheduleDialog({
))}
</div>
) : (
<div className="text-sm text-muted-foreground">尚未选择客服组</div>
<div className="text-sm text-muted-foreground">{t("agentTeamSchedule.noSelectedTeams")}</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="batch-schedule-start-date">开始日期</Label>
<Label htmlFor="batch-schedule-start-date">{t("agentTeamSchedule.startDate")}</Label>
<Input
id="batch-schedule-start-date"
type="date"
@@ -356,7 +369,7 @@ export function BatchScheduleDialog({
/>
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-end-date">结束日期</Label>
<Label htmlFor="batch-schedule-end-date">{t("agentTeamSchedule.endDate")}</Label>
<Input
id="batch-schedule-end-date"
type="date"
@@ -368,7 +381,7 @@ export function BatchScheduleDialog({
</div>
<div className="space-y-2">
<Label>星期</Label>
<Label>{t("agentTeamSchedule.weekday")}</Label>
<div className="flex flex-wrap gap-2">
{weekdayOptions.map((option) => {
const selected = selectedWeekdays.has(option.value)
@@ -391,7 +404,7 @@ export function BatchScheduleDialog({
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="batch-schedule-start-time">开始时间</Label>
<Label htmlFor="batch-schedule-start-time">{t("agentTeamSchedule.startTime")}</Label>
<Input
id="batch-schedule-start-time"
type="time"
@@ -400,7 +413,7 @@ export function BatchScheduleDialog({
/>
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-end-time">结束时间</Label>
<Label htmlFor="batch-schedule-end-time">{t("agentTeamSchedule.endTime")}</Label>
<Input
id="batch-schedule-end-time"
type="time"
@@ -411,11 +424,11 @@ export function BatchScheduleDialog({
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-remark">备注</Label>
<Label htmlFor="batch-schedule-remark">{t("agentTeamSchedule.remark")}</Label>
<Textarea
id="batch-schedule-remark"
rows={4}
placeholder="请输入备注"
placeholder={t("agentTeamSchedule.remarkPlaceholder")}
value={form.remark}
onChange={(event) => updateForm({ remark: event.target.value })}
/>
@@ -425,13 +438,14 @@ export function BatchScheduleDialog({
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm text-muted-foreground">
共 {preview?.total ?? 0} 条排班
{hasConflict ? ",存在冲突,请返回调整" : ",确认无冲突后可生成"}
{hasConflict
? t("agentTeamSchedule.previewSummaryConflict", { total: preview?.total ?? 0 })
: t("agentTeamSchedule.previewSummaryReady", { total: preview?.total ?? 0 })}
</div>
{hasConflict ? (
<Badge variant="destructive">存在冲突</Badge>
<Badge variant="destructive">{t("agentTeamSchedule.hasConflict")}</Badge>
) : (
<Badge variant="secondary">无冲突</Badge>
<Badge variant="secondary">{t("agentTeamSchedule.noConflict")}</Badge>
)}
</div>
<div className="overflow-x-auto rounded-lg border">
@@ -439,12 +453,12 @@ export function BatchScheduleDialog({
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>客服组</TableHead>
<TableHead>日期</TableHead>
<TableHead>星期</TableHead>
<TableHead>时间</TableHead>
<TableHead>备注</TableHead>
<TableHead>状态</TableHead>
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead>{t("agentTeamSchedule.date")}</TableHead>
<TableHead>{t("agentTeamSchedule.weekday")}</TableHead>
<TableHead>{t("agentTeamSchedule.time")}</TableHead>
<TableHead>{t("agentTeamSchedule.remark")}</TableHead>
<TableHead>{t("agentTeamSchedule.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -456,11 +470,11 @@ export function BatchScheduleDialog({
)}
>
<TableCell>
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div>
<div className="text-xs text-muted-foreground">组ID:{item.teamId}</div>
<div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
</TableCell>
<TableCell>{item.date}</TableCell>
<TableCell>{getWeekdayLabel(item.weekday)}</TableCell>
<TableCell>{getWeekdayLabel(item.weekday, t)}</TableCell>
<TableCell>
{item.startAt.slice(11, 16)} - {item.endAt.slice(11, 16)}
</TableCell>
@@ -468,10 +482,10 @@ export function BatchScheduleDialog({
<TableCell>
{item.conflict ? (
<span className="font-medium">
{item.conflictReason || "排班冲突"}
{item.conflictReason || t("agentTeamSchedule.conflictFallback")}
</span>
) : (
<span className="text-muted-foreground">可生成</span>
<span className="text-muted-foreground">{t("agentTeamSchedule.canGenerate")}</span>
)}
</TableCell>
</TableRow>
@@ -479,7 +493,7 @@ export function BatchScheduleDialog({
{preview && preview.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
没有可生成的排班
{t("agentTeamSchedule.emptyPreview")}
</TableCell>
</TableRow>
) : null}
@@ -500,7 +514,7 @@ export function BatchScheduleDialog({
disabled={submitting}
>
<ArrowLeftIcon />
返回修改
{t("agentTeamSchedule.backToEdit")}
</Button>
) : null}
<Button
@@ -509,12 +523,12 @@ export function BatchScheduleDialog({
onClick={() => handleOpenChange(false)}
disabled={busy}
>
取消
{t("agentTeamSchedule.cancel")}
</Button>
{step === "form" ? (
<Button type="button" onClick={() => void handlePreview()} disabled={busy}>
{previewing ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
预览
{t("agentTeamSchedule.preview")}
</Button>
) : (
<Button
@@ -523,7 +537,7 @@ export function BatchScheduleDialog({
disabled={submitting || hasConflict || !preview || !previewPayload || preview.items.length === 0}
>
{submitting ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
生成排班
{t("agentTeamSchedule.generate")}
</Button>
)}
</DialogFooter>
@@ -58,7 +58,7 @@ export function formatDateTimeValue(date: Date) {
}
export function formatMonthTitle(monthStart: Date) {
return `${monthStart.getFullYear()}年${String(monthStart.getMonth() + 1).padStart(2, "0")}月`
return `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}`
}
function formatDate(date: Date) {
@@ -13,7 +13,17 @@ import { cn, formatDateTime } from "@/lib/utils"
import { isSameLocalDay } from "./calendar-date-range"
import { buildDayTimeLayout } from "./calendar-time-layout"
const weekDayNames = ["一", "二", "三", "四", "五", "六", "日"]
type TFunction = (key: string, values?: Record<string, string | number>) => string
const weekDayKeys = [
"weekdayShortMon",
"weekdayShortTue",
"weekdayShortWed",
"weekdayShortThu",
"weekdayShortFri",
"weekdayShortSat",
"weekdayShortSun",
] as const
const dayMs = 24 * 60 * 60 * 1000
const minuteMs = 60 * 1000
const minDurationMs = 15 * minuteMs
@@ -31,6 +41,7 @@ type ScheduleCalendarProps = {
onEdit: (item: AdminAgentTeamSchedule) => void
onMove: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
onResize: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
t: TFunction
}
type DragState =
@@ -226,6 +237,7 @@ export function ScheduleCalendar({
onEdit,
onMove,
onResize,
t,
}: ScheduleCalendarProps) {
const days = buildCalendarDays(calendarStart, calendarEnd)
const defaultTeamID = teams[0]?.id ?? 0
@@ -250,7 +262,7 @@ export function ScheduleCalendar({
return {
itemId: state.item.id,
date: null,
label: "拖到日历日期格内",
label: t("agentTeamSchedule.dropInsideCalendar"),
invalid: true,
x: pointerEvent.clientX,
y: pointerEvent.clientY,
@@ -265,7 +277,7 @@ export function ScheduleCalendar({
return {
itemId: state.item.id,
date,
label: "不能修改历史日期",
label: t("agentTeamSchedule.historyReadonly"),
invalid: true,
x: pointerEvent.clientX,
y: pointerEvent.clientY,
@@ -274,11 +286,11 @@ export function ScheduleCalendar({
const point = { x: pointerEvent.clientX, y: pointerEvent.clientY }
if (state.type === "move") {
return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, "无法移动到这里")
return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, t("agentTeamSchedule.cannotMoveHere"))
}
const payload = buildResizePayload(state.item, state.edge, getPointerDateInCell(pointerEvent, cell))
return buildPreviewFromPayload(state.item.id, date, payload, point, "不能跨天或少于 15 分钟")
return buildPreviewFromPayload(state.item.id, date, payload, point, t("agentTeamSchedule.resizeInvalid"))
}
function cleanupPointerInteraction(
@@ -365,7 +377,7 @@ export function ScheduleCalendar({
if (teams.length === 0 && !loading) {
return (
<div className="flex min-h-64 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground">
暂无客服组,无法展示排班日历
{t("agentTeamSchedule.noTeamsCalendar")}
</div>
)
}
@@ -426,7 +438,7 @@ export function ScheduleCalendar({
<div className="flex shrink-0 items-center gap-1">
{today ? (
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground">
今天
{t("agentTeamSchedule.today")}
</span>
) : null}
{historical ? null : <CalendarPlusIcon className="size-3.5 text-muted-foreground" />}
@@ -434,7 +446,7 @@ export function ScheduleCalendar({
</div>
<div className="space-y-1">
{daySchedules.slice(0, 5).map((item) => {
const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || `客服组#${item.teamId}`
const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || t("agentTeamSchedule.teamFallback", { id: item.teamId })
const busy = savingId === item.id
const active = interactionPreview?.itemId === item.id
const timeLayout = dayTimeLayout.items.get(item.id)
@@ -512,7 +524,7 @@ export function ScheduleCalendar({
)
})}
{daySchedules.length > 5 ? (
<div className="text-xs text-muted-foreground">还有 {daySchedules.length - 5} 条</div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.moreItems", { count: daySchedules.length - 5 })}</div>
) : null}
</div>
</div>
@@ -528,7 +540,7 @@ export function ScheduleCalendar({
return (
<div key={date} className="grid grid-cols-[112px_minmax(0,1fr)]">
<div className="border-r bg-muted/40 px-3 py-3 text-sm font-medium">
<div>周{weekDayNames[dayIndex] ?? ""}</div>
<div>{t(`agentTeamSchedule.${weekDayKeys[dayIndex] ?? "weekdayShortMon"}`)}</div>
<div className="mt-1 text-xs font-normal text-muted-foreground">{date.slice(5)}</div>
</div>
{renderDayCell(day, dayIndex, {
@@ -562,9 +574,9 @@ export function ScheduleCalendar({
return (
<div className="min-w-[960px] overflow-hidden rounded-lg border bg-background">
<div className="grid grid-cols-7 border-b bg-muted/40">
{weekDayNames.map((name) => (
<div key={name} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
周{name}
{weekDayKeys.map((key) => (
<div key={key} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
{t(`agentTeamSchedule.${key}`)}
</div>
))}
</div>
@@ -1,7 +1,7 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Controller, type Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
@@ -27,8 +27,11 @@ import {
type AdminAgentTeamSchedule,
type CreateAdminAgentTeamSchedulePayload,
fetchAgentTeamSchedule,
fetchAgentTeamsAll
fetchAgentTeamsAll,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type TFunction = (key: string, values?: Record<string, string | number>) => string
type ScheduleEditDialogProps = {
open: boolean
@@ -47,10 +50,18 @@ const emptyForm: EditForm = {
remark: "",
}
const editFormSchema = z.object({
teamId: z.string().trim().regex(/^\d+$/, "请选择客服组"),
startAt: z.string().trim().min(1, "开始时间不能为空"),
endAt: z.string().trim().min(1, "结束时间不能为空"),
type EditForm = {
teamId: string
startAt: string
endAt: string
remark: string
}
function createEditFormSchema(t: TFunction) {
return z.object({
teamId: z.string().trim().regex(/^\d+$/, t("agentTeamSchedule.teamRequired")),
startAt: z.string().trim().min(1, t("agentTeamSchedule.startRequired")),
endAt: z.string().trim().min(1, t("agentTeamSchedule.endRequired")),
remark: z.string().trim(),
}).superRefine((value, ctx) => {
const startAt = parseDateTimeLocal(value.startAt)
@@ -62,7 +73,7 @@ const editFormSchema = z.object({
ctx.addIssue({
code: "custom",
path: ["endAt"],
message: "结束时间必须晚于开始时间",
message: t("agentTeamSchedule.endAfterStart"),
})
return
}
@@ -70,24 +81,18 @@ const editFormSchema = z.object({
ctx.addIssue({
code: "custom",
path: ["endAt"],
message: "单条排班记录不能跨天",
message: t("agentTeamSchedule.singleDayOnly"),
})
}
if (startAt < startOfLocalDay(new Date())) {
ctx.addIssue({
code: "custom",
path: ["startAt"],
message: "不能添加或修改历史日期的排班",
message: t("agentTeamSchedule.historyReadonly"),
})
}
})
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) {
@@ -180,6 +185,7 @@ function ScheduleEditDialogBody({
onSubmit,
onDelete,
}: ScheduleEditDialogBodyProps) {
const t = useI18n()
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [loading, setLoading] = useState(false)
const loadOptions = useCallback(async () => {
@@ -187,14 +193,15 @@ function ScheduleEditDialogBody({
const teamsData = await fetchAgentTeamsAll()
setTeams(teamsData)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadOptionsFailed"))
}
}, [])
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
}, [t])
const editFormSchema = useMemo(() => createEditFormSchema(t), [t])
const editFormResolver = useMemo(
() => zodResolver(editFormSchema) as Resolver<EditForm>,
[editFormSchema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -218,13 +225,13 @@ function ScheduleEditDialogBody({
const data = await fetchAgentTeamSchedule(itemId)
reset(buildForm(data))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班详情失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadDetailFailed"))
} finally {
setLoading(false)
}
}
void loadDetail()
}, [defaultValues, itemId, reset])
}, [defaultValues, itemId, reset, t])
useEffect(() => {
void loadOptions()
@@ -237,18 +244,18 @@ function ScheduleEditDialogBody({
return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑客服组排班" : "新建客服组排班"}</DialogTitle>
<DialogTitle>{itemId ? t("agentTeamSchedule.editTitle") : t("agentTeamSchedule.createTitle")}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("agentTeamSchedule.loading")}</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>
<FieldLabel>{t("agentTeamSchedule.team")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -260,8 +267,9 @@ function ScheduleEditDialogBody({
value: String(team.id),
label: team.name,
}))}
placeholder="请选择客服组"
searchPlaceholder="搜索客服组"
placeholder={t("agentTeamSchedule.teamRequired")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={t("agentTeamSchedule.emptyTeam")}
onChange={field.onChange}
/>
)}
@@ -272,14 +280,14 @@ function ScheduleEditDialogBody({
</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>
<FieldLabel htmlFor="agent-team-schedule-start-at">{t("agentTeamSchedule.startTime")}</FieldLabel>
<FieldContent>
<Input id="agent-team-schedule-start-at" type="datetime-local" min={minDateTime} {...register("startAt")} />
<FieldError errors={[errors.startAt]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.endAt}>
<FieldLabel htmlFor="agent-team-schedule-end-at">结束时间</FieldLabel>
<FieldLabel htmlFor="agent-team-schedule-end-at">{t("agentTeamSchedule.endTime")}</FieldLabel>
<FieldContent>
<Input id="agent-team-schedule-end-at" type="datetime-local" min={minDateTime} {...register("endAt")} />
<FieldError errors={[errors.endAt]} />
@@ -287,23 +295,23 @@ function ScheduleEditDialogBody({
</Field>
</div>
<Field>
<FieldLabel htmlFor="agent-team-schedule-remark">备注</FieldLabel>
<FieldLabel htmlFor="agent-team-schedule-remark">{t("agentTeamSchedule.remark")}</FieldLabel>
<FieldContent>
<Textarea id="agent-team-schedule-remark" rows={4} placeholder="请输入备注" {...register("remark")} />
<Textarea id="agent-team-schedule-remark" rows={4} placeholder={t("agentTeamSchedule.remarkPlaceholder")} {...register("remark")} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
{itemId && onDelete ? (
<Button type="button" variant="destructive" onClick={() => void onDelete(itemId)} disabled={saving}>
删除
{t("agentTeamSchedule.delete")}
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
取消
{t("agentTeamSchedule.cancel")}
</Button>
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("agentTeamSchedule.saving") : t("agentTeamSchedule.save")}
</Button>
</DialogFooter>
</form>
+51 -44
View File
@@ -49,6 +49,7 @@ import {
type PageResult,
type UpdateAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
import { BatchScheduleDialog } from "./_components/batch-schedule-dialog"
import { ScheduleCalendar } from "./_components/calendar"
@@ -56,7 +57,6 @@ import {
addDays,
addMonths,
formatDateTimeValue,
formatMonthTitle,
formatWeekTitle,
startOfDay,
startOfMonth,
@@ -79,6 +79,7 @@ function isHistoricalSchedule(item: AdminAgentTeamSchedule) {
}
export default function DashboardAgentTeamSchedulesPage() {
const t = useI18n()
const [viewMode, setViewMode] = useState<ViewMode>("month")
const [teamFilterInput, setTeamFilterInput] = useState("all")
const [teamFilter, setTeamFilter] = useState("all")
@@ -120,11 +121,11 @@ export default function DashboardAgentTeamSchedulesPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadFailed"))
} finally {
setLoading(false)
}
}, [limit, page, teamFilter])
}, [limit, page, t, teamFilter])
const loadCalendarData = useCallback(async () => {
setCalendarLoading(true)
@@ -138,20 +139,20 @@ export default function DashboardAgentTeamSchedulesPage() {
})
setCalendarItems(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班日历失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadCalendarFailed"))
} finally {
setCalendarLoading(false)
}
}, [monthStart, teamFilter, viewMode, weekStart])
}, [monthStart, t, teamFilter, viewMode, weekStart])
const loadTeams = useCallback(async () => {
try {
const data = await fetchAgentTeamsAll()
setTeams(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
}
}, [])
}, [t])
const refreshActiveView = useCallback(async () => {
await Promise.all([
@@ -194,7 +195,7 @@ export default function DashboardAgentTeamSchedulesPage() {
function openEditDialog(item: AdminAgentTeamSchedule) {
if (isHistoricalSchedule(item)) {
toast.error("不能修改历史日期的排班")
toast.error(t("agentTeamSchedule.historyReadonly"))
return
}
setDialogDefaults(null)
@@ -221,17 +222,17 @@ export default function DashboardAgentTeamSchedulesPage() {
try {
if (editingItem) {
await updateAgentTeamSchedule({ id: editingItem.id, ...payload })
toast.success("已更新客服组排班")
toast.success(t("agentTeamSchedule.updated"))
} else {
await createAgentTeamSchedule(payload)
toast.success("已创建客服组排班")
toast.success(t("agentTeamSchedule.created"))
}
setDialogOpen(false)
setEditingItem(null)
setDialogDefaults(null)
await refreshActiveView()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.saveFailed"))
} finally {
setSaving(false)
}
@@ -245,13 +246,13 @@ export default function DashboardAgentTeamSchedulesPage() {
setActionLoadingId(id)
try {
await deleteAgentTeamSchedule(id)
toast.success("已删除客服组排班")
toast.success(t("agentTeamSchedule.deleted"))
setDialogOpen(false)
setEditingItem(null)
setDialogDefaults(null)
await refreshActiveView()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -264,16 +265,16 @@ export default function DashboardAgentTeamSchedulesPage() {
async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) {
const startAt = parseLocalDateTime(payload.startAt)
if (startAt && startAt < startOfDay(new Date())) {
toast.error("不能修改历史日期的排班")
toast.error(t("agentTeamSchedule.historyReadonly"))
return
}
setActionLoadingId(payload.id)
try {
await updateAgentTeamSchedule(payload)
toast.success("已更新客服组排班")
toast.success(t("agentTeamSchedule.updated"))
await loadCalendarData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.updateFailed"))
await loadCalendarData()
} finally {
setActionLoadingId(null)
@@ -298,7 +299,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("month")}
>
<CalendarDaysIcon />
月
{t("agentTeamSchedule.month")}
</Button>
<Button
variant={viewMode === "week" ? "default" : "outline"}
@@ -306,7 +307,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("week")}
>
<CalendarRangeIcon />
周
{t("agentTeamSchedule.week")}
</Button>
<Button
variant={viewMode === "list" ? "default" : "outline"}
@@ -314,37 +315,42 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("list")}
>
<ListIcon />
列表
{t("agentTeamSchedule.list")}
</Button>
</ButtonGroup>
{viewMode === "month" ? (
<ButtonGroup>
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label="上一月">
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label={t("agentTeamSchedule.prevMonth")}>
<ChevronLeftIcon />
</Button>
<Button variant="outline" size="sm" onClick={() => setMonthStart(startOfMonth(new Date()))}>
本月
{t("agentTeamSchedule.thisMonth")}
</Button>
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label="下一月">
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label={t("agentTeamSchedule.nextMonth")}>
<ChevronRightIcon />
</Button>
</ButtonGroup>
) : null}
{viewMode === "week" ? (
<ButtonGroup>
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label="上一周">
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label={t("agentTeamSchedule.prevWeek")}>
<ChevronLeftIcon />
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
本周
{t("agentTeamSchedule.thisWeek")}
</Button>
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label="下一周">
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label={t("agentTeamSchedule.nextWeek")}>
<ChevronRightIcon />
</Button>
</ButtonGroup>
) : null}
{viewMode === "month" ? (
<div className="text-sm text-muted-foreground">{formatMonthTitle(monthStart)}</div>
<div className="text-sm text-muted-foreground">
{t("agentTeamSchedule.monthTitle", {
year: monthStart.getFullYear(),
month: String(monthStart.getMonth() + 1).padStart(2, "0"),
})}
</div>
) : null}
{viewMode === "week" ? (
<div className="text-sm text-muted-foreground">{formatWeekTitle(weekStart)}</div>
@@ -352,7 +358,7 @@ export default function DashboardAgentTeamSchedulesPage() {
{viewMode !== "list" ? (
<Button variant="outline" size="sm" onClick={goToToday}>
<CalendarSearchIcon />
今天
{t("agentTeamSchedule.today")}
</Button>
) : null}
</div>
@@ -362,18 +368,18 @@ export default function DashboardAgentTeamSchedulesPage() {
<OptionCombobox
value={teamFilterInput}
options={[
{ value: "all", label: "全部客服组" },
{ value: "all", label: t("agentTeamSchedule.allTeams") },
...teams.map((team) => ({ value: String(team.id), label: team.name })),
]}
placeholder="筛选客服组"
searchPlaceholder="搜索客服组"
emptyText="未找到客服组"
placeholder={t("agentTeamSchedule.filterTeam")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={t("agentTeamSchedule.emptyTeam")}
onChange={(value) => setTeamFilterInput(value)}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={refreshing}>
<SearchIcon />
查询
{t("agentTeamSchedule.query")}
</Button>
<Button
variant="outline"
@@ -381,15 +387,15 @@ export default function DashboardAgentTeamSchedulesPage() {
disabled={refreshing}
>
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} />
刷新
{t("agentTeamSchedule.refresh")}
</Button>
<Button variant="outline" onClick={() => setBatchDialogOpen(true)}>
<LayersIcon />
批量排班
{t("agentTeamSchedule.batch")}
</Button>
<Button onClick={() => openCreateDialog()}>
<PlusIcon />
新建
{t("agentTeamSchedule.new")}
</Button>
</div>
</div>
@@ -409,6 +415,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onEdit={openEditDialog}
onMove={handleCalendarUpdate}
onResize={handleCalendarUpdate}
t={t}
/>
</div>
) : (
@@ -417,9 +424,9 @@ export default function DashboardAgentTeamSchedulesPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>客服组</TableHead>
<TableHead>时间范围</TableHead>
<TableHead className="w-[92px] text-right">操作</TableHead>
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead>{t("agentTeamSchedule.timeRange")}</TableHead>
<TableHead className="w-[92px] text-right">{t("agentTeamSchedule.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -431,8 +438,8 @@ export default function DashboardAgentTeamSchedulesPage() {
<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 className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
</div>
</div>
</TableCell>
@@ -443,12 +450,12 @@ export default function DashboardAgentTeamSchedulesPage() {
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)} disabled={isHistoricalSchedule(item)}>
编辑
{t("agentTeamSchedule.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.startAt}`}
aria-label={t("agentTeamSchedule.moreActions", { name: item.startAt })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -458,7 +465,7 @@ export default function DashboardAgentTeamSchedulesPage() {
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("agentTeamSchedule.deleting") : t("agentTeamSchedule.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -469,7 +476,7 @@ export default function DashboardAgentTeamSchedulesPage() {
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
没有匹配的客服组排班
{t("agentTeamSchedule.emptyRows")}
</TableCell>
</TableRow>
) : null}
+78 -85
View File
@@ -2,12 +2,13 @@
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 { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
import { ImageInput } from "@/components/image-input";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
@@ -30,13 +31,6 @@ import {
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 {
@@ -44,13 +38,12 @@ import {
fetchUsersAll,
type AdminAgentProfile,
type AdminUser,
type CreateAdminAgentProfilePayload
type CreateAdminAgentProfilePayload,
} from "@/lib/api/admin";
import {
ServiceStatus,
ServiceStatusLabels,
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import { ServiceStatus } from "@/lib/generated/enums";
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type AgentEditDialogProps = {
open: boolean;
@@ -61,7 +54,6 @@ type AgentEditDialogProps = {
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
};
const serviceStatusOptions = getEnumOptions(ServiceStatusLabels);
const emptyForm: EditForm = {
userId: "",
teamId: "",
@@ -76,40 +68,49 @@ const emptyForm: EditForm = {
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, "展示名不能为空"),
type EditForm = {
userId: string;
teamId: string;
agentCode: string;
displayName: string;
avatar: string;
serviceStatus: "0" | "1";
maxConcurrentCount: string;
priorityLevel: string;
autoAssignEnabled: boolean;
receiveOfflineMessage: boolean;
remark: string;
};
function createEditFormSchema(t: TFunction) {
return z.object({
userId: z.string().trim().min(1, t("agentProfile.userRequired")),
teamId: z.string().trim().min(1, t("agentProfile.teamRequired")),
agentCode: z.string().trim().min(1, t("agentProfile.agentCodeRequired")),
displayName: z.string().trim().min(1, t("agentProfile.displayNameRequired")),
avatar: z.string().trim(),
serviceStatus: z.enum(["0", "1"], {
message: "请选择客服状态",
message: t("agentProfile.statusRequired"),
}),
maxConcurrentCount: z
.string()
.trim()
.regex(/^\d+$/, "最大并发必须是大于等于 0 的整数"),
.regex(/^\d+$/, t("agentProfile.maxConcurrentInvalid")),
priorityLevel: z
.string()
.trim()
.regex(/^-?\d+$/, "优先级必须是整数"),
.regex(/^-?\d+$/, t("agentProfile.priorityInvalid")),
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 getServiceStatusOptions(t: TFunction) {
return [
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
];
}
function buildForm(item: AdminAgentProfile | null): EditForm {
@@ -203,6 +204,7 @@ function AgentEditDialogBody({
onOpenChange,
onSubmit,
}: AgentEditDialogBodyProps) {
const t = useI18n();
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSelectOpen, setUserSelectOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -210,6 +212,7 @@ function AgentEditDialogBody({
value: String(user.id),
label: `${user.nickname || user.username} (${user.username})`,
}));
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
const loadOptions = useCallback(async () => {
try {
const [usersData] = await Promise.all([
@@ -217,14 +220,15 @@ function AgentEditDialogBody({
]);
setUsers(usersData);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadOptionsFailed"));
}
}, []);
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
}, [t]);
const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(editFormSchema) as Resolver<EditForm>,
[editFormSchema],
);
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: buildFormWithDefaultTeam(null, defaultTeamId),
});
@@ -247,13 +251,13 @@ function AgentEditDialogBody({
const data = await fetchAgentProfile(itemId);
reset(buildForm(data));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服档案详情失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadDetailFailed"));
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, defaultTeamId, reset]);
}, [itemId, defaultTeamId, reset, t]);
useEffect(() => {
if (open) {
@@ -271,7 +275,7 @@ function AgentEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑客服档案" : "新建客服档案"}
title={itemId ? t("agentProfile.editTitle") : t("agentProfile.createTitle")}
size="lg"
footer={
<>
@@ -281,17 +285,17 @@ function AgentEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("agentProfile.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("agentProfile.saving") : t("agentProfile.save")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("agentProfile.loading")}</div>
</div>
) : (
<form
@@ -301,7 +305,7 @@ function AgentEditDialogBody({
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.userId}>
<FieldLabel>关联用户</FieldLabel>
<FieldLabel>{t("agentProfile.linkedUser")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -324,7 +328,7 @@ function AgentEditDialogBody({
<span className="truncate">
{userOptions.find(
(option) => option.value === field.value,
)?.label ?? "请选择用户"}
)?.label ?? t("agentProfile.selectUser")}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
@@ -333,9 +337,9 @@ function AgentEditDialogBody({
align="start"
>
<Command>
<CommandInput placeholder="搜索用户..." />
<CommandInput placeholder={t("agentProfile.searchUser")} />
<CommandList>
<CommandEmpty>没有匹配的用户</CommandEmpty>
<CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
<CommandGroup>
{userOptions.map((option) => (
<CommandItem
@@ -367,11 +371,11 @@ function AgentEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.displayName}>
<FieldLabel htmlFor="agent-display-name">展示名</FieldLabel>
<FieldLabel htmlFor="agent-display-name">{t("agentProfile.displayName")}</FieldLabel>
<FieldContent>
<Input
id="agent-display-name"
placeholder="请输入展示名"
placeholder={t("agentProfile.displayNamePlaceholder")}
{...register("displayName")}
/>
<FieldError errors={[errors.displayName]} />
@@ -381,11 +385,11 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.agentCode}>
<FieldLabel htmlFor="agent-code">客服工号</FieldLabel>
<FieldLabel htmlFor="agent-code">{t("agentProfile.agentCodeLabel")}</FieldLabel>
<FieldContent>
<Input
id="agent-code"
placeholder="例如:A1001"
placeholder={t("agentProfile.agentCodePlaceholder")}
{...register("agentCode")}
/>
<FieldError errors={[errors.agentCode]} />
@@ -393,7 +397,7 @@ function AgentEditDialogBody({
</Field>
<Field className="min-h-32">
<FieldLabel>头像</FieldLabel>
<FieldLabel>{t("agentProfile.avatar")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -404,7 +408,7 @@ function AgentEditDialogBody({
onChange={field.onChange}
disabled={saving}
prefix="avatar"
placeholder="上传头像"
placeholder={t("agentProfile.avatarUpload")}
className="size-16 rounded-full"
/>
)}
@@ -415,31 +419,20 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.serviceStatus}>
<FieldLabel>客服状态</FieldLabel>
<FieldLabel>{t("agentProfile.serviceStatus")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="serviceStatus"
render={({ field }) => (
<Select
<OptionCombobox
options={serviceStatusOptions}
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>
onChange={field.onChange}
placeholder={t("agentProfile.selectStatus")}
searchPlaceholder={t("agentProfile.searchStatus")}
emptyText={t("agentProfile.emptyStatus")}
/>
)}
/>
<FieldError errors={[errors.serviceStatus]} />
@@ -450,7 +443,7 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.maxConcurrentCount}>
<FieldLabel htmlFor="agent-max-concurrent-count">
最大并发
{t("agentProfile.maxConcurrent")}
</FieldLabel>
<FieldContent>
<Input
@@ -463,7 +456,7 @@ function AgentEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.priorityLevel}>
<FieldLabel htmlFor="agent-priority-level">优先级</FieldLabel>
<FieldLabel htmlFor="agent-priority-level">{t("agentProfile.priority")}</FieldLabel>
<FieldContent>
<Input
id="agent-priority-level"
@@ -478,7 +471,7 @@ function AgentEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>参与自动分配</FieldLabel>
<FieldLabel>{t("agentProfile.autoAssignEnabled")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -493,7 +486,7 @@ function AgentEditDialogBody({
</FieldContent>
</Field>
<Field>
<FieldLabel>离线接收消息</FieldLabel>
<FieldLabel>{t("agentProfile.receiveOfflineMessage")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -510,12 +503,12 @@ function AgentEditDialogBody({
</div>
<Field>
<FieldLabel htmlFor="agent-remark">备注</FieldLabel>
<FieldLabel htmlFor="agent-remark">{t("agentProfile.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="agent-remark"
rows={4}
placeholder="请输入备注"
placeholder={t("agentProfile.remarkPlaceholder")}
{...register("remark")}
/>
</FieldContent>
@@ -1,9 +1,9 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { Controller, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
@@ -14,6 +14,7 @@ import {
fetchUsersAll,
type AdminUser,
} from "@/lib/api/admin";
import { OptionCombobox } from "@/components/option-combobox";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -42,16 +43,11 @@ import {
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";
import { useI18n } from "@/i18n/provider";
import { Status } from "@/lib/generated/enums";
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type TeamEditDialogProps = {
open: boolean;
@@ -61,13 +57,6 @@ type TeamEditDialogProps = {
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",
@@ -76,22 +65,32 @@ const emptyForm: EditForm = {
remark: "",
};
const editFormSchema = z.object({
name: z.string().trim().min(1, "客服组名称不能为空"),
leaderUserId: z.string().trim().regex(/^\d+$/, "组长用户不合法"),
type EditForm = {
name: string;
leaderUserId: string;
status: string;
description: string;
remark: string;
};
function createEditFormSchema(t: TFunction) {
return z.object({
name: z.string().trim().min(1, t("agentProfile.teamNameRequired")),
leaderUserId: z.string().trim().regex(/^\d+$/, t("agentProfile.leaderInvalid")),
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: "请选择状态",
message: t("agentProfile.teamStatusRequired"),
}),
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 getStatusOptions(t: TFunction) {
return [
{ value: String(Status.Ok), label: t("agentProfile.enabled") },
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
];
}
function buildForm(item: AdminAgentTeam | null): EditForm {
if (!item) {
@@ -146,6 +145,7 @@ function TeamEditDialogBody({
onOpenChange,
onSubmit,
}: TeamEditDialogBodyProps) {
const t = useI18n();
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSelectOpen, setUserSelectOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -153,19 +153,21 @@ function TeamEditDialogBody({
value: String(user.id),
label: `${user.nickname || user.username} (${user.username})`,
}));
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const loadUsers = useCallback(async () => {
try {
const data = await fetchUsersAll();
setUsers(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载用户选项失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadUsersFailed"));
}
}, []);
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
}, [t]);
const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(editFormSchema) as Resolver<EditForm>,
[editFormSchema],
);
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
@@ -188,13 +190,13 @@ function TeamEditDialogBody({
const data = await fetchAgentTeam(itemId);
reset(buildForm(data));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组详情失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamDetailFailed"));
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset]);
}, [itemId, reset, t]);
useEffect(() => {
void loadUsers();
@@ -207,28 +209,28 @@ function TeamEditDialogBody({
return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑" : "新建"}</DialogTitle>
<DialogTitle>{itemId ? t("agentProfile.teamEditTitle") : t("agentProfile.teamCreateTitle")}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("agentProfile.loading")}</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="agent-team-name">客服组名称</FieldLabel>
<FieldLabel htmlFor="agent-team-name">{t("agentProfile.teamName")}</FieldLabel>
<FieldContent>
<Input
id="agent-team-name"
placeholder="请输入客服组名称"
placeholder={t("agentProfile.teamNamePlaceholder")}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.leaderUserId}>
<FieldLabel>组长</FieldLabel>
<FieldLabel>{t("agentProfile.leader")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -247,19 +249,19 @@ function TeamEditDialogBody({
>
<span className="truncate">
{field.value === "0"
? "暂不设置"
: userOptions.find((option) => option.value === field.value)?.label ?? "请选择组长"}
? t("agentProfile.noLeader")
: userOptions.find((option) => option.value === field.value)?.label ?? t("agentProfile.selectLeader")}
</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="搜索用户..." />
<CommandInput placeholder={t("agentProfile.searchUser")} />
<CommandList>
<CommandEmpty>没有匹配的用户</CommandEmpty>
<CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
<CommandGroup>
<CommandItem
value="暂不设置"
value={t("agentProfile.noLeader")}
onSelect={() => {
field.onChange("0");
setUserSelectOpen(false);
@@ -268,7 +270,7 @@ function TeamEditDialogBody({
<CheckIcon
className={`mr-2 size-4 ${field.value === "0" ? "opacity-100" : "opacity-0"}`}
/>
暂不设置
{t("agentProfile.noLeader")}
</CommandItem>
{userOptions.map((option) => (
<CommandItem
@@ -298,54 +300,42 @@ function TeamEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.status}>
<FieldLabel>状态</FieldLabel>
<FieldLabel>{t("agentProfile.status")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select
<OptionCombobox
options={statusOptions}
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>
onChange={field.onChange}
placeholder={t("agentProfile.selectStatus")}
searchPlaceholder={t("agentProfile.searchStatus")}
emptyText={t("agentProfile.emptyStatus")}
/>
)}
/>
<FieldError errors={[errors.status]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="agent-team-description">职责说明</FieldLabel>
<FieldLabel htmlFor="agent-team-description">{t("agentProfile.description")}</FieldLabel>
<FieldContent>
<Input
id="agent-team-description"
placeholder="例如:负责售前咨询与线索转化"
placeholder={t("agentProfile.descriptionPlaceholder")}
{...register("description")}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="agent-team-remark">备注</FieldLabel>
<FieldLabel htmlFor="agent-team-remark">{t("agentProfile.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="agent-team-remark"
rows={4}
placeholder="请输入备注"
placeholder={t("agentProfile.remarkPlaceholder")}
{...register("remark")}
/>
</FieldContent>
@@ -358,10 +348,10 @@ function TeamEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("agentProfile.cancel")}
</Button>
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("agentProfile.saving") : t("agentProfile.save")}
</Button>
</DialogFooter>
</form>
@@ -31,8 +31,8 @@ import {
type AdminAgentTeam,
type CreateAdminAgentTeamPayload,
} from "@/lib/api/admin";
import { Status, StatusLabels } from "@/lib/generated/enums";
import { getEnumLabel } from "@/lib/enums";
import { Status } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils";
type AgentTeamSidebarProps = {
@@ -41,17 +41,21 @@ type AgentTeamSidebarProps = {
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;
function getStatusTabs(t: (key: string, values?: Record<string, string | number>) => string) {
return [
{ value: "all", label: t("agentProfile.all") },
{ value: String(Status.Ok), label: t("agentProfile.enabled") },
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
] as const;
}
export function AgentTeamSidebar({
selectedTeamId,
onSelectTeam,
onTeamsChange,
}: AgentTeamSidebarProps) {
const t = useI18n();
const statusTabs = getStatusTabs(t);
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] =
useState<(typeof statusTabs)[number]["value"]>("all");
@@ -69,11 +73,11 @@ export function AgentTeamSidebar({
setTeams(data);
onTeamsChange?.(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamsFailed"));
} finally {
setLoading(false);
}
}, [onTeamsChange]);
}, [onTeamsChange, t]);
useEffect(() => {
void loadData();
@@ -135,16 +139,16 @@ export function AgentTeamSidebar({
try {
if (editingItem) {
await updateAgentTeam({ id: editingItem.id, ...payload });
toast.success(`已更新客服组:${editingItem.name}`);
toast.success(t("agentProfile.teamUpdated", { name: editingItem.name }));
} else {
await createAgentTeam(payload);
toast.success(`已创建客服组:${payload.name}`);
toast.success(t("agentProfile.teamCreated", { name: payload.name }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.teamSaveFailed"));
} finally {
setSaving(false);
}
@@ -154,10 +158,10 @@ export function AgentTeamSidebar({
setActionLoadingId(item.id);
try {
await deleteAgentTeam(item.id);
toast.success(`已删除客服组:${item.name}`);
toast.success(t("agentProfile.teamDeleted", { name: item.name }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.teamDeleteFailed"));
} finally {
setActionLoadingId(null);
}
@@ -169,7 +173,7 @@ export function AgentTeamSidebar({
<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 className="text-sm font-medium">{t("agentProfile.teamTitle")}</div>
</div>
</div>
<div className="relative mt-3">
@@ -177,7 +181,7 @@ export function AgentTeamSidebar({
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索客服组"
placeholder={t("agentProfile.searchTeams")}
className="pl-9"
/>
</div>
@@ -235,7 +239,7 @@ export function AgentTeamSidebar({
item.status === Status.Ok ? "secondary" : "outline"
}
>
{getEnumLabel(StatusLabels, item.status as Status)}
{item.status === Status.Ok ? t("agentProfile.enabled") : t("agentProfile.disabled")}
</Badge>
</button>
<DropdownMenu>
@@ -247,21 +251,21 @@ export function AgentTeamSidebar({
className="opacity-0 group-hover:opacity-100"
/>
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("agentProfile.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<Pencil />
编辑
{t("agentProfile.edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("agentProfile.deleting") : t("agentProfile.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -269,7 +273,7 @@ export function AgentTeamSidebar({
))}
{!loading && filteredTeams.length === 0 ? (
<div className="px-2 py-10 text-center text-sm text-muted-foreground">
没有匹配的客服组
{t("agentProfile.noTeams")}
</div>
) : null}
</div>
+63 -74
View File
@@ -21,6 +21,7 @@ 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 { OptionCombobox } from "@/components/option-combobox";
import {
DropdownMenu,
DropdownMenuContent,
@@ -28,13 +29,6 @@ import {
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,
@@ -53,25 +47,29 @@ import {
type CreateAdminAgentProfilePayload,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
ServiceStatus,
ServiceStatusLabels,
} from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { ServiceStatus } 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),
];
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function getStatusLabel(value: number) {
return getEnumLabel(ServiceStatusLabels, value as ServiceStatus);
function getServiceStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("agentProfile.allStatuses") },
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
];
}
function getStatusLabel(value: number, t: TFunction) {
return getServiceStatusOptions(t).find((item) => item.value === String(value))?.label ?? String(value);
}
export default function DashboardAgentsPage() {
const t = useI18n();
const serviceStatusOptions = getServiceStatusOptions(t);
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -108,11 +106,11 @@ export default function DashboardAgentsPage() {
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服档案失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.loadFailed"));
} finally {
setLoading(false);
}
}, [agentCode, displayName, limit, page, selectedTeam?.id, statusFilter]);
}, [agentCode, displayName, limit, page, selectedTeam?.id, statusFilter, t]);
useEffect(() => {
void loadData();
@@ -193,16 +191,16 @@ export default function DashboardAgentsPage() {
try {
if (editingItem) {
await updateAgentProfile({ id: editingItem.id, ...payload });
toast.success(`已更新客服档案:${editingItem.displayName}`);
toast.success(t("agentProfile.updated", { name: editingItem.displayName }));
} else {
await createAgentProfile(payload);
toast.success(`已创建客服档案:${payload.displayName}`);
toast.success(t("agentProfile.created", { name: payload.displayName }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服档案失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.saveFailed"));
} finally {
setSaving(false);
}
@@ -212,10 +210,10 @@ export default function DashboardAgentsPage() {
setActionLoadingId(item.id);
try {
await deleteAgentProfile(item.id);
toast.success(`已删除客服档案:${item.displayName}`);
toast.success(t("agentProfile.deleted", { name: item.displayName }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服档案失败");
toast.error(error instanceof Error ? error.message : t("agentProfile.deleteFailed"));
} finally {
setActionLoadingId(null);
}
@@ -241,7 +239,7 @@ export default function DashboardAgentsPage() {
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 ? "展开客服组列表" : "折叠客服组列表"}
aria-label={sidebarCollapsed ? t("agentProfile.expandTeams") : t("agentProfile.collapseTeams")}
>
{sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" />
@@ -256,7 +254,7 @@ export default function DashboardAgentsPage() {
actions={
<Button onClick={openCreateDialog}>
<PlusIcon />
新建
{t("agentProfile.new")}
</Button>
}
>
@@ -268,7 +266,7 @@ export default function DashboardAgentsPage() {
setDisplayNameInput(event.target.value)
}
onKeyDown={handleFilterKeyDown}
placeholder="按展示名筛选"
placeholder={t("agentProfile.filterDisplayName")}
className="pl-9"
/>
</div>
@@ -276,37 +274,26 @@ export default function DashboardAgentsPage() {
value={agentCodeInput}
onChange={(event) => setAgentCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按客服工号筛选"
placeholder={t("agentProfile.filterAgentCode")}
className="w-full sm:w-44"
/>
<Select
value={statusFilterInput}
onValueChange={(value) =>
setStatusFilterInput(value ?? "all")
}
>
<SelectTrigger className="w-full sm: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>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
options={serviceStatusOptions}
placeholder={t("agentProfile.allStatuses")}
searchPlaceholder={t("agentProfile.searchStatus")}
emptyText={t("agentProfile.emptyStatus")}
onChange={(value) => setStatusFilterInput(value ?? "all")}
/>
</div>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
<SearchIcon />
查询
{t("agentProfile.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
@@ -328,12 +315,12 @@ export default function DashboardAgentsPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>客服</TableHead>
<TableHead>服务规则</TableHead>
<TableHead>分配策略</TableHead>
<TableHead>最近时间</TableHead>
<TableHead>{t("agentProfile.columnAgent")}</TableHead>
<TableHead>{t("agentProfile.columnRules")}</TableHead>
<TableHead>{t("agentProfile.columnDispatch")}</TableHead>
<TableHead>{t("agentProfile.columnRecent")}</TableHead>
<TableHead className="w-[92px] text-right">
操作
{t("agentProfile.columnActions")}
</TableHead>
</TableRow>
</TableHeader>
@@ -360,21 +347,23 @@ export default function DashboardAgentsPage() {
<div className="text-xs text-muted-foreground">
{item.nickname ||
item.username ||
`用户#${item.userId}`}
t("agentProfile.userFallback", { id: item.userId })}
</div>
<div className="mt-1 text-xs text-muted-foreground">
工号:{item.agentCode}
{t("agentProfile.agentCode", { code: item.agentCode })}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{getStatusLabel(item.serviceStatus)}
{getStatusLabel(item.serviceStatus, t)}
</Badge>
<div className="mt-2 text-sm text-muted-foreground">
最大并发 {item.maxConcurrentCount} / 优先级{" "}
{item.priorityLevel}
{t("agentProfile.capacityPriority", {
capacity: item.maxConcurrentCount,
priority: item.priorityLevel,
})}
</div>
</TableCell>
<TableCell>
@@ -385,8 +374,8 @@ export default function DashboardAgentsPage() {
}
>
{item.autoAssignEnabled
? "自动分配"
: "不自动分配"}
? t("agentProfile.autoAssign")
: t("agentProfile.noAutoAssign")}
</Badge>
<Badge
variant={
@@ -396,17 +385,17 @@ export default function DashboardAgentsPage() {
}
>
{item.receiveOfflineMessage
? "离线接收"
: "离线不接收"}
? t("agentProfile.offlineReceive")
: t("agentProfile.noOfflineReceive")}
</Badge>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
在线:{formatDateTime(item.lastOnlineAt)}
{t("agentProfile.onlineAt", { time: formatDateTime(item.lastOnlineAt) })}
</div>
<div className="text-sm text-muted-foreground">
状态:{formatDateTime(item.lastStatusAt)}
{t("agentProfile.statusAt", { time: formatDateTime(item.lastStatusAt) })}
</div>
</TableCell>
<TableCell className="text-right">
@@ -416,14 +405,14 @@ export default function DashboardAgentsPage() {
size="sm"
onClick={() => openEditDialog(item)}
>
编辑
{t("agentProfile.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" />
}
aria-label={`更多操作 ${item.displayName}`}
aria-label={t("agentProfile.moreActions", { name: item.displayName })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -437,8 +426,8 @@ export default function DashboardAgentsPage() {
>
<Trash2Icon />
{actionLoadingId === item.id
? "删除中..."
: "删除"}
? t("agentProfile.deleting")
: t("agentProfile.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -450,11 +439,11 @@ export default function DashboardAgentsPage() {
<DashboardTableStateRow
colSpan={5}
loading={loading}
loadingText="正在加载客服档案..."
loadingText={t("agentProfile.loadingRows")}
emptyText={
selectedTeam
? "当前客服组下没有匹配的客服档案"
: "没有匹配的客服档案"
? t("agentProfile.emptyTeamRows")
: t("agentProfile.emptyRows")
}
/>
) : null}
+152 -139
View File
@@ -54,15 +54,12 @@ import {
type MCPToolSourceType,
type SkillDefinition,
} from "@/lib/api/admin";
import { getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import {
AIAgentFallbackMode,
AIAgentFallbackModeLabels,
AIAgentHandoffMode,
AIAgentHandoffModeLabels,
AIModelType,
IMConversationServiceMode,
IMConversationServiceModeLabels,
Status,
} from "@/lib/generated/enums";
@@ -90,49 +87,43 @@ type EditDialogProps = {
onSubmit: (payload: CreateAIAgentPayload) => Promise<void>;
};
const schema = z.object({
name: z.string().trim().min(1, "名称不能为空"),
description: z.string().trim(),
aiConfigId: z.string().trim().regex(/^\d+$/, "请选择 AI 配置"),
serviceMode: z.string().trim().min(1, "请选择服务模式"),
systemPrompt: z.string().trim(),
welcomeMessage: z.string().trim(),
replyTimeoutSeconds: z
.number()
.min(0, "回复超时秒数必须是大于等于 0 的整数"),
handoffMode: z.string().trim().min(1, "请选择转人工模式"),
fallbackMode: z.string().trim().min(1, "请选择兜底策略"),
fallbackMessage: z.string().trim(),
});
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type EditForm = z.infer<typeof schema>;
type EditForm = {
name: string;
description: string;
aiConfigId: string;
serviceMode: string;
systemPrompt: string;
welcomeMessage: string;
replyTimeoutSeconds: number;
handoffMode: string;
fallbackMode: string;
fallbackMessage: string;
};
const resolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>;
function getServiceModeOptions(t: TFunction) {
return [
{ value: String(IMConversationServiceMode.AIOnly), label: t("aiAgent.serviceAiOnly") },
{ value: String(IMConversationServiceMode.HumanOnly), label: t("aiAgent.serviceHumanOnly") },
{ value: String(IMConversationServiceMode.AIFirst), label: t("aiAgent.serviceAiFirst") },
];
}
const serviceModeOptions = getEnumOptions(IMConversationServiceModeLabels).map(
(option) => ({
value: String(option.value),
label: option.label,
}),
);
function getHandoffModeOptions(t: TFunction) {
return [
{ value: String(AIAgentHandoffMode.WaitPool), label: t("aiAgent.handoffWaitPool") },
{ value: String(AIAgentHandoffMode.DefaultTeamPool), label: t("aiAgent.handoffDefaultTeamPool") },
{ value: String(AIAgentHandoffMode.AIHoldAndNotify), label: t("aiAgent.handoffAiHoldAndNotify") },
];
}
const handoffModeOptions = getEnumOptions(AIAgentHandoffModeLabels).map(
(option) => ({
value: String(option.value),
label: option.label,
}),
);
const fallbackModeOptions = getEnumOptions(AIAgentFallbackModeLabels).map(
(option) => ({
value: String(option.value),
label: option.label,
}),
);
function getFallbackModeOptions(t: TFunction) {
return [
{ value: String(AIAgentFallbackMode.NoAnswer), label: t("aiAgent.fallbackNoAnswer") },
{ value: String(AIAgentFallbackMode.SuggestRetry), label: t("aiAgent.fallbackSuggestRetry") },
];
}
function buildForm(item: AIAgent | null): EditForm {
if (!item) {
@@ -220,12 +211,34 @@ function EditDialogBody({
onSubmit,
}: EditDialogProps) {
const formId = "ai-agent-edit-form";
const t = useI18n();
const [loading, setLoading] = useState(false);
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
const schema = useMemo(
() =>
z.object({
name: z.string().trim().min(1, t("aiAgent.nameRequired")),
description: z.string().trim(),
aiConfigId: z.string().trim().regex(/^\d+$/, t("aiAgent.aiConfigRequired")),
serviceMode: z.string().trim().min(1, t("aiAgent.serviceModeRequired")),
systemPrompt: z.string().trim(),
welcomeMessage: z.string().trim(),
replyTimeoutSeconds: z
.number()
.min(0, t("aiAgent.replyTimeoutInvalid")),
handoffMode: z.string().trim().min(1, t("aiAgent.handoffModeRequired")),
fallbackMode: z.string().trim().min(1, t("aiAgent.fallbackModeRequired")),
fallbackMessage: z.string().trim(),
}),
[t],
);
const resolver = useMemo(
() => zodResolver(schema as never) as Resolver<EditForm>,
[schema],
);
const serviceModeOptions = useMemo(() => getServiceModeOptions(t), [t]);
const handoffModeOptions = useMemo(() => getHandoffModeOptions(t), [t]);
const fallbackModeOptions = useMemo(() => getFallbackModeOptions(t), [t]);
const form = useForm<EditForm>({
resolver,
defaultValues: buildForm(null),
});
@@ -296,14 +309,14 @@ function EditDialogBody({
setGraphToolToAdd("");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 AI Agent 详情失败",
error instanceof Error ? error.message : t("aiAgent.loadDetailFailed"),
);
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset]);
}, [itemId, reset, t]);
useEffect(() => {
async function loadAIConfigs() {
@@ -314,12 +327,12 @@ function EditDialogBody({
setAIConfigs(data);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 AI 配置失败",
error instanceof Error ? error.message : t("aiAgent.loadAiConfigFailed"),
);
}
}
void loadAIConfigs();
}, []);
}, [t]);
useEffect(() => {
async function loadAgentTeams() {
@@ -327,11 +340,11 @@ function EditDialogBody({
const data = await fetchAgentTeamsAll();
setAgentTeams(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组失败");
toast.error(error instanceof Error ? error.message : t("aiAgent.loadTeamsFailed"));
}
}
void loadAgentTeams();
}, []);
}, [t]);
useEffect(() => {
async function loadKnowledgeBases() {
@@ -341,11 +354,11 @@ function EditDialogBody({
});
setKnowledgeBases(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载知识库失败");
toast.error(error instanceof Error ? error.message : t("aiAgent.loadKnowledgeFailed"));
}
}
void loadKnowledgeBases();
}, []);
}, [t]);
useEffect(() => {
async function loadSkills() {
@@ -355,11 +368,11 @@ function EditDialogBody({
});
setSkills(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Skills 失败");
toast.error(error instanceof Error ? error.message : t("aiAgent.loadSkillsFailed"));
}
}
void loadSkills();
}, []);
}, [t]);
useEffect(() => {
async function loadDirectToolOptions() {
@@ -376,7 +389,7 @@ function EditDialogBody({
autoInjected: tool.autoInjected,
groupLabel:
tool.sourceType === "builtin"
? "内置工具"
? t("aiAgent.builtinTools")
: tool.serverCode,
meta: {
toolCode: tool.toolCode,
@@ -398,12 +411,12 @@ function EditDialogBody({
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 Direct Tools 失败",
error instanceof Error ? error.message : t("aiAgent.loadDirectToolsFailed"),
);
}
}
void loadDirectToolOptions();
}, []);
}, [t]);
const aiConfigOptions = useMemo(
() =>
@@ -508,13 +521,13 @@ function EditDialogBody({
const directToolsGrouped = useMemo(() => {
const groups = new Map<string, DirectToolItem[]>();
for (const tool of directTools) {
const groupLabel = tool.serverCode || "未分组";
const groupLabel = tool.serverCode || t("aiAgent.ungrouped");
const current = groups.get(groupLabel) ?? [];
current.push(tool);
groups.set(groupLabel, current);
}
return Array.from(groups.entries());
}, [directTools]);
}, [directTools, t]);
const addableGraphToolOptions = useMemo(
() =>
@@ -545,7 +558,7 @@ function EditDialogBody({
const handoffMode = watch("handoffMode");
const selectedHandoffModeLabel =
handoffModeOptions.find((item) => item.value === handoffMode)?.label ??
"未选择";
t("aiAgent.notSelected");
async function onFormSubmit(values: EditForm) {
await onSubmit(
@@ -653,7 +666,7 @@ function EditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑 AI Agent" : "新建 AI Agent"}
title={itemId ? t("aiAgent.editTitle") : t("aiAgent.createTitle")}
size="xl"
defaultFullscreen
footer={
@@ -663,17 +676,17 @@ function EditDialogBody({
variant="outline"
onClick={() => onOpenChange(false)}
>
取消
{t("aiAgent.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("aiAgent.saving") : t("aiAgent.save")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("aiAgent.loading")}</div>
</div>
) : (
<form
@@ -682,11 +695,11 @@ function EditDialogBody({
className="space-y-6"
>
<SectionCard
title="基础信息"
title={t("aiAgent.sectionBasic")}
>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ai-agent-name">名称</FieldLabel>
<FieldLabel htmlFor="ai-agent-name">{t("aiAgent.name")}</FieldLabel>
<FieldContent>
<Input id="ai-agent-name" {...register("name")} />
<FieldError errors={[errors.name]} />
@@ -694,7 +707,7 @@ function EditDialogBody({
</Field>
<Field data-invalid={!!errors.aiConfigId}>
<FieldLabel>AI 配置</FieldLabel>
<FieldLabel>{t("aiAgent.aiConfig")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -703,9 +716,9 @@ function EditDialogBody({
<OptionCombobox
value={field.value}
options={aiConfigOptions}
placeholder="请选择 AI 配置"
searchPlaceholder="搜索 AI 配置"
emptyText="未找到 AI 配置"
placeholder={t("aiAgent.selectAiConfig")}
searchPlaceholder={t("aiAgent.searchAiConfig")}
emptyText={t("aiAgent.emptyAiConfig")}
onChange={field.onChange}
/>
)}
@@ -715,7 +728,7 @@ function EditDialogBody({
</Field>
<Field data-invalid={!!errors.serviceMode}>
<FieldLabel>服务模式</FieldLabel>
<FieldLabel>{t("aiAgent.columnServiceMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -724,9 +737,9 @@ function EditDialogBody({
<OptionCombobox
value={field.value}
options={serviceModeOptions}
placeholder="请选择服务模式"
searchPlaceholder="搜索服务模式"
emptyText="未找到服务模式"
placeholder={t("aiAgent.selectServiceMode")}
searchPlaceholder={t("aiAgent.searchServiceMode")}
emptyText={t("aiAgent.emptyServiceMode")}
onChange={field.onChange}
/>
)}
@@ -737,14 +750,14 @@ function EditDialogBody({
</div>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="ai-agent-description">描述</FieldLabel>
<FieldLabel htmlFor="ai-agent-description">{t("aiAgent.description")}</FieldLabel>
<FieldContent>
<Textarea id="ai-agent-description" {...register("description")} />
<FieldError errors={[errors.description]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.welcomeMessage}>
<FieldLabel htmlFor="ai-agent-welcome-message">欢迎语</FieldLabel>
<FieldLabel htmlFor="ai-agent-welcome-message">{t("aiAgent.welcomeMessage")}</FieldLabel>
<FieldContent>
<Textarea
id="ai-agent-welcome-message"
@@ -757,7 +770,7 @@ function EditDialogBody({
<Field data-invalid={!!errors.systemPrompt}>
<FieldLabel htmlFor="ai-agent-system-prompt">
系统提示词
{t("aiAgent.systemPrompt")}
</FieldLabel>
<FieldContent>
<Textarea
@@ -771,12 +784,12 @@ function EditDialogBody({
</SectionCard>
<SectionCard
title="服务策略"
title={t("aiAgent.sectionStrategy")}
>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(320px,1.15fr)]">
<div className="space-y-4">
<Field data-invalid={!!errors.handoffMode}>
<FieldLabel>转人工模式</FieldLabel>
<FieldLabel>{t("aiAgent.handoffMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -785,9 +798,9 @@ function EditDialogBody({
<OptionCombobox
value={field.value}
options={handoffModeOptions}
placeholder="请选择转人工模式"
searchPlaceholder="搜索转人工模式"
emptyText="未找到转人工模式"
placeholder={t("aiAgent.selectHandoffMode")}
searchPlaceholder={t("aiAgent.searchHandoffMode")}
emptyText={t("aiAgent.emptyHandoffMode")}
onChange={field.onChange}
/>
)}
@@ -801,7 +814,7 @@ function EditDialogBody({
htmlFor="ai-agent-reply-timeout-seconds"
className="flex items-center gap-1"
>
回复超时秒数
{t("aiAgent.replyTimeoutSeconds")}
<Popover>
<PopoverTrigger
render={
@@ -815,7 +828,7 @@ function EditDialogBody({
/>
<PopoverContent side="top" align="start" className="max-w-xs">
<PopoverDescription>
AI 自动回复的异步执行超时时间。填 0 时使用系统默认值 180 秒。
{t("aiAgent.replyTimeoutHelp")}
</PopoverDescription>
</PopoverContent>
</Popover>
@@ -834,28 +847,28 @@ function EditDialogBody({
</div>
<div className="rounded-lg border bg-muted/10 p-4">
<div className="mb-1 text-sm font-medium">客服组</div>
<div className="mb-1 text-sm font-medium">{t("aiAgent.teams")}</div>
<div className="mb-4 text-xs text-muted-foreground">
当前转人工模式:{selectedHandoffModeLabel}
{t("aiAgent.currentHandoffMode", { mode: selectedHandoffModeLabel })}
{handoffMode ===
String(AIAgentHandoffMode.DefaultTeamPool)
? "。该模式要求至少配置一个客服组。"
: "。仅在涉及转人工时生效。"}
? t("aiAgent.handoffNeedsTeam")
: t("aiAgent.handoffOnlyWhenNeeded")}
</div>
<Field>
<FieldContent className="space-y-3">
<OptionCombobox
value={teamToAdd}
options={addableTeamOptions}
placeholder="请选择客服组"
searchPlaceholder="搜索客服组"
emptyText="未找到客服组"
placeholder={t("aiAgent.selectTeam")}
searchPlaceholder={t("aiAgent.searchTeam")}
emptyText={t("aiAgent.emptyTeam")}
onChange={handleAddTeam}
/>
<div className="flex flex-wrap gap-2">
{selectedTeamOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
未配置客服组
{t("aiAgent.noTeams")}
</span>
) : (
selectedTeamOptions.map((option) => (
@@ -873,7 +886,7 @@ function EditDialogBody({
onClick={() =>
handleRemoveTeam(Number(option.value))
}
aria-label={`移除客服组 ${option.label}`}
aria-label={t("aiAgent.removeTeam", { name: option.label })}
>
<Trash2Icon className="size-3" />
</Button>
@@ -888,19 +901,19 @@ function EditDialogBody({
</SectionCard>
<SectionCard
title="能力配置"
title={t("aiAgent.sectionCapabilities")}
>
<Tabs defaultValue="knowledge" className="gap-4">
<TabsList className="w-fit">
<TabsTrigger value="knowledge">知识库</TabsTrigger>
<TabsTrigger value="skills">Skills</TabsTrigger>
<TabsTrigger value="direct-tools">Direct Tools</TabsTrigger>
<TabsTrigger value="graph-tools">Graph Tools</TabsTrigger>
<TabsTrigger value="knowledge">{t("aiAgent.knowledgeTab")}</TabsTrigger>
<TabsTrigger value="skills">{t("aiAgent.skillsTab")}</TabsTrigger>
<TabsTrigger value="direct-tools">{t("aiAgent.directToolsTab")}</TabsTrigger>
<TabsTrigger value="graph-tools">{t("aiAgent.graphToolsTab")}</TabsTrigger>
</TabsList>
<TabsContent value="knowledge" className="space-y-4">
<div className="text-xs text-muted-foreground">
至少选择一个知识库,可调整知识库优先级。
{t("aiAgent.knowledgeHint")}
</div>
<Field data-invalid={selectedKnowledgeIds.length === 0}>
<FieldContent className="space-y-3">
@@ -909,9 +922,9 @@ function EditDialogBody({
<OptionCombobox
value={knowledgeToAdd}
options={addableKnowledgeOptions}
placeholder="选择并添加知识库"
searchPlaceholder="搜索知识库"
emptyText="没有可添加的知识库"
placeholder={t("aiAgent.selectKnowledge")}
searchPlaceholder={t("aiAgent.searchKnowledge")}
emptyText={t("aiAgent.emptyKnowledge")}
onChange={handleAddKnowledge}
/>
</div>
@@ -922,12 +935,12 @@ function EditDialogBody({
onClick={() => handleAddKnowledge(knowledgeToAdd)}
>
<PlusIcon />
添加
{t("aiAgent.add")}
</Button>
</div>
{selectedKnowledgeIds.length === 0 ? (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
请至少选择一个知识库。
{t("aiAgent.knowledgeRequired")}
</div>
) : (
<div className="space-y-2 rounded-md border p-3">
@@ -975,14 +988,14 @@ function EditDialogBody({
</div>
)}
{selectedKnowledgeIds.length === 0 ? (
<FieldError errors={[{ message: "请至少选择一个知识库" }]} />
<FieldError errors={[{ message: t("aiAgent.knowledgeRequired") }]} />
) : null}
</FieldContent>
</Field>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-1">
<Field data-invalid={!!errors.fallbackMode}>
<FieldLabel>兜底策略</FieldLabel>
<FieldLabel>{t("aiAgent.fallbackMode")}</FieldLabel>
<FieldContent className="space-y-3">
<Controller
control={control}
@@ -991,9 +1004,9 @@ function EditDialogBody({
<OptionCombobox
value={field.value}
options={fallbackModeOptions}
placeholder="请选择兜底策略"
searchPlaceholder="搜索兜底策略"
emptyText="未找到兜底策略"
placeholder={t("aiAgent.selectFallbackMode")}
searchPlaceholder={t("aiAgent.searchFallbackMode")}
emptyText={t("aiAgent.emptyFallbackMode")}
onChange={field.onChange}
/>
)}
@@ -1004,11 +1017,11 @@ function EditDialogBody({
<Field data-invalid={!!errors.fallbackMessage}>
<FieldLabel htmlFor="ai-agent-fallback-message">
兜底文案
{t("aiAgent.fallbackMessage")}
</FieldLabel>
<FieldContent>
<div className="mb-1 text-xs text-muted-foreground">
知识库未命中或命中证据不足时,会按这里的策略和文案进行兜底回复。
{t("aiAgent.fallbackMessageHint")}
</div>
<Textarea
id="ai-agent-fallback-message"
@@ -1023,7 +1036,7 @@ function EditDialogBody({
<TabsContent value="skills" className="space-y-4">
<div className="text-xs text-muted-foreground">
用于固定业务流程和多步任务编排。
{t("aiAgent.skillsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
@@ -1032,9 +1045,9 @@ function EditDialogBody({
<OptionCombobox
value={skillToAdd}
options={addableSkillOptions}
placeholder="选择并添加 Skill"
searchPlaceholder="搜索 Skill"
emptyText="没有可添加的 Skill"
placeholder={t("aiAgent.selectSkill")}
searchPlaceholder={t("aiAgent.searchSkill")}
emptyText={t("aiAgent.emptySkill")}
onChange={handleAddSkill}
/>
</div>
@@ -1045,13 +1058,13 @@ function EditDialogBody({
onClick={() => handleAddSkill(skillToAdd)}
>
<PlusIcon />
添加
{t("aiAgent.add")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{selectedSkillOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
不绑定 Skill 时,自动路由只会走知识库或转人工。
{t("aiAgent.noSkillsHint")}
</span>
) : (
selectedSkillOptions.map((option) => (
@@ -1069,7 +1082,7 @@ function EditDialogBody({
onClick={() =>
handleRemoveSkill(Number(option.value))
}
aria-label={`移除 Skill ${option.label}`}
aria-label={t("aiAgent.removeSkill", { name: option.label })}
>
<Trash2Icon className="size-3" />
</Button>
@@ -1083,7 +1096,7 @@ function EditDialogBody({
<TabsContent value="direct-tools" className="space-y-4">
<div className="text-xs text-muted-foreground">
仅用于外部 MCP 工具的低风险、原子化查询。
{t("aiAgent.directToolsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
@@ -1092,9 +1105,9 @@ function EditDialogBody({
<OptionCombobox
value={directToolGroupToAdd}
options={directToolGroupOptions}
placeholder="选择工具分组"
searchPlaceholder="搜索工具分组"
emptyText="没有可用的工具分组"
placeholder={t("aiAgent.selectToolGroup")}
searchPlaceholder={t("aiAgent.searchToolGroup")}
emptyText={t("aiAgent.emptyToolGroup")}
onChange={(value) => {
setDirectToolGroupToAdd(value);
setDirectToolToAdd("");
@@ -1105,9 +1118,9 @@ function EditDialogBody({
<OptionCombobox
value={directToolToAdd}
options={addableDirectToolOptions}
placeholder="选择 Direct Tool"
searchPlaceholder="搜索 Direct Tool"
emptyText="没有可添加的 Direct Tool"
placeholder={t("aiAgent.selectDirectTool")}
searchPlaceholder={t("aiAgent.searchDirectTool")}
emptyText={t("aiAgent.emptyDirectTool")}
onChange={handleAddDirectTool}
/>
</div>
@@ -1118,13 +1131,13 @@ function EditDialogBody({
onClick={() => handleAddDirectTool(directToolToAdd)}
>
<PlusIcon />
添加
{t("aiAgent.add")}
</Button>
</div>
<div className="space-y-3">
{directTools.length === 0 ? (
<span className="text-sm text-muted-foreground">
不配置 Direct Tool 时,Agent 不会直接调用外部或内置工具,只会依赖知识库、Skill 和普通回复。
{t("aiAgent.noDirectToolsHint")}
</span>
) : (
directToolsGrouped.map(([groupLabel, tools]) => (
@@ -1156,7 +1169,7 @@ function EditDialogBody({
onClick={() =>
handleRemoveDirectTool(value)
}
aria-label={`移除 Direct Tool ${value}`}
aria-label={t("aiAgent.removeDirectTool", { name: value })}
>
<Trash2Icon className="size-3" />
</Button>
@@ -1174,7 +1187,7 @@ function EditDialogBody({
<TabsContent value="graph-tools" className="space-y-4">
<div className="text-xs text-muted-foreground">
用于建单、转人工等系统内置流程,不再混放到 Direct Tools 中。
{t("aiAgent.graphToolsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
@@ -1183,9 +1196,9 @@ function EditDialogBody({
<OptionCombobox
value={graphToolToAdd}
options={addableGraphToolOptions}
placeholder="选择 Graph Tool"
searchPlaceholder="搜索 Graph Tool"
emptyText="没有可添加的 Graph Tool"
placeholder={t("aiAgent.selectGraphTool")}
searchPlaceholder={t("aiAgent.searchGraphTool")}
emptyText={t("aiAgent.emptyGraphTool")}
onChange={handleAddGraphTool}
/>
</div>
@@ -1196,13 +1209,13 @@ function EditDialogBody({
onClick={() => handleAddGraphTool(graphToolToAdd)}
>
<PlusIcon />
添加
{t("aiAgent.add")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{graphTools.length === 0 ? (
<span className="text-sm text-muted-foreground">
不配置 Graph Tool 时,Agent 不会暴露建单/转人工等内置流程工具。
{t("aiAgent.noGraphToolsHint")}
</span>
) : (
graphTools.map((toolCode) => {
@@ -1225,7 +1238,7 @@ function EditDialogBody({
size="icon"
className="size-5"
onClick={() => handleRemoveGraphTool(toolCode)}
aria-label={`移除 Graph Tool ${toolCode}`}
aria-label={t("aiAgent.removeGraphTool", { name: toolCode })}
>
<Trash2Icon className="size-3" />
</Button>
+80 -56
View File
@@ -73,34 +73,48 @@ import {
type CreateAIAgentPayload,
type PageResult,
} from "@/lib/api/admin";
import {
IMConversationServiceModeLabels,
Status,
StatusLabels,
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { IMConversationServiceMode, Status } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
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,
})),
];
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function getStatusLabel(value: string) {
function getStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("aiAgent.allStatuses") },
{ value: String(Status.Ok), label: t("aiAgent.enabled") },
{ value: String(Status.Disabled), label: t("aiAgent.disabled") },
{ value: String(Status.Deleted), label: t("status.deleted") },
];
}
function getStatusLabel(value: string, t: TFunction) {
return (
statusOptions.find((item) => item.value === value)?.label ?? "全部状态"
getStatusOptions(t).find((item) => item.value === value)?.label ??
t("aiAgent.allStatuses")
);
}
function getServiceModeLabel(mode: number, t: TFunction) {
switch (mode) {
case IMConversationServiceMode.AIOnly:
return t("aiAgent.serviceAiOnly");
case IMConversationServiceMode.HumanOnly:
return t("aiAgent.serviceHumanOnly");
case IMConversationServiceMode.AIFirst:
return t("aiAgent.serviceAiFirst");
default:
return "-";
}
}
type SortableAIAgentRowProps = {
item: AIAgent;
disabled: boolean;
actionLoadingId: number | null;
t: TFunction;
openEditDialog: (item: AIAgent) => void;
handleToggleStatus: (item: AIAgent) => void;
handleDelete: (item: AIAgent) => void;
@@ -110,6 +124,7 @@ function SortableAIAgentRow({
item,
disabled,
actionLoadingId,
t,
openEditDialog,
handleToggleStatus,
handleDelete,
@@ -154,7 +169,7 @@ function SortableAIAgentRow({
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
aria-label={t("aiAgent.dragSort", { name: item.name })}
{...attributes}
{...listeners}
>
@@ -175,15 +190,14 @@ function SortableAIAgentRow({
{item.aiConfigName || "-"}
</TableCell>
<TableCell>
{getEnumLabel(
IMConversationServiceModeLabels,
item.serviceMode as keyof typeof IMConversationServiceModeLabels,
)}
{getServiceModeLabel(item.serviceMode, t)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{knowledgeIds.length === 0 ? (
<span className="text-sm text-muted-foreground">未配置</span>
<span className="text-sm text-muted-foreground">
{t("aiAgent.notConfigured")}
</span>
) : (
knowledgeBaseNames.map((name, index) => (
<Badge key={knowledgeIds[index] ?? `${item.id}-${index}`} variant="secondary">
@@ -196,7 +210,7 @@ function SortableAIAgentRow({
<TableCell>
<div className="flex flex-wrap gap-1">
{skills.length === 0 ? (
<span className="text-sm text-muted-foreground">仅RAG</span>
<span className="text-sm text-muted-foreground">{t("aiAgent.ragOnly")}</span>
) : (
skills.map((skill) => (
<Badge key={skill.id} variant="outline">
@@ -214,7 +228,9 @@ function SortableAIAgentRow({
</div>
<div className="flex flex-wrap gap-1">
{directToolServerCodes.length === 0 ? (
<span className="text-sm text-muted-foreground">未绑定 MCP Server</span>
<span className="text-sm text-muted-foreground">
{t("aiAgent.noMcpServer")}
</span>
) : (
directToolServerCodes.map((serverCode) => (
<Badge key={serverCode} variant="outline">
@@ -231,12 +247,12 @@ function SortableAIAgentRow({
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("aiAgent.toggleStatus", { name: item.name })}
/>
<Badge
variant={item.status === Status.Ok ? "default" : "secondary"}
>
{getStatusLabel(String(item.status))}
{getStatusLabel(String(item.status), t)}
</Badge>
</div>
</TableCell>
@@ -247,14 +263,14 @@ function SortableAIAgentRow({
size="sm"
onClick={() => openEditDialog(item)}
>
编辑
{t("aiAgent.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" className="ml-auto" />
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("aiAgent.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -264,7 +280,7 @@ function SortableAIAgentRow({
onClick={() => void handleToggleStatus(item)}
>
<PowerIcon className="size-4" />
{item.status === Status.Ok ? "停用" : "启用"}
{item.status === Status.Ok ? t("aiAgent.stop") : t("aiAgent.enabled")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
@@ -272,7 +288,7 @@ function SortableAIAgentRow({
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="size-4" />
删除
{t("aiAgent.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -283,6 +299,8 @@ function SortableAIAgentRow({
}
export default function DashboardAIAgentsPage() {
const t = useI18n();
const statusOptions = getStatusOptions(t);
const [nameInput, setNameInput] = useState("");
const [statusInput, setStatusInput] = useState("all");
const [name, setName] = useState("");
@@ -324,12 +342,12 @@ export default function DashboardAIAgentsPage() {
setResult(data);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 AI Agent 失败",
error instanceof Error ? error.message : t("aiAgent.loadFailed"),
);
} finally {
setLoading(false);
}
}, [limit, name, page, status]);
}, [limit, name, page, status, t]);
useEffect(() => {
void loadData();
@@ -367,17 +385,17 @@ export default function DashboardAIAgentsPage() {
try {
if (editingItemId) {
await updateAIAgent({ id: editingItemId, ...payload });
toast.success(`已更新 AI Agent:${payload.name}`);
toast.success(t("aiAgent.updated", { name: payload.name }));
} else {
const created = await createAIAgent(payload);
toast.success(`已创建 AI Agent:${created.name}`);
toast.success(t("aiAgent.created", { name: created.name }));
}
setDialogOpen(false);
setEditingItemId(null);
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存 AI Agent 失败",
error instanceof Error ? error.message : t("aiAgent.saveFailed"),
);
} finally {
setSaving(false);
@@ -391,12 +409,15 @@ export default function DashboardAIAgentsPage() {
item.status === Status.Ok ? Status.Disabled : Status.Ok;
await updateAIAgentStatus(item.id, nextStatus);
toast.success(
`已${nextStatus === Status.Ok ? "启用" : "停用"}:${item.name}`,
t("aiAgent.statusChanged", {
name: item.name,
status: nextStatus === Status.Ok ? t("aiAgent.enabled") : t("aiAgent.stop"),
}),
);
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "更新 AI Agent 状态失败",
error instanceof Error ? error.message : t("aiAgent.statusUpdateFailed"),
);
} finally {
setActionLoadingId(null);
@@ -407,11 +428,11 @@ export default function DashboardAIAgentsPage() {
setActionLoadingId(item.id);
try {
await deleteAIAgent(item.id);
toast.success(`已删除 AI Agent:${item.name}`);
toast.success(t("aiAgent.deleted", { name: item.name }));
await loadData();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "删除 AI Agent 失败",
error instanceof Error ? error.message : t("aiAgent.deleteFailed"),
);
} finally {
setActionLoadingId(null);
@@ -440,14 +461,14 @@ export default function DashboardAIAgentsPage() {
try {
await updateAIAgentSort(nextResults.map((item) => item.id));
toast.success("AI Agent 排序已更新");
toast.success(t("aiAgent.sortUpdated"));
await loadData();
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}));
toast.error(error instanceof Error ? error.message : "更新排序失败");
toast.error(error instanceof Error ? error.message : t("aiAgent.sortUpdateFailed"));
} finally {
setSorting(false);
}
@@ -465,11 +486,11 @@ export default function DashboardAIAgentsPage() {
disabled={loading}
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
刷新
{t("aiAgent.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
新建 AI Agent
{t("aiAgent.new")}
</Button>
</>
}
@@ -478,22 +499,22 @@ export default function DashboardAIAgentsPage() {
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选"
placeholder={t("aiAgent.filterName")}
className="w-full sm:w-56"
/>
<div className="w-full sm:w-52">
<OptionCombobox
value={statusInput}
options={statusOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("aiAgent.allStatuses")}
searchPlaceholder={t("aiAgent.searchStatus")}
emptyText={t("aiAgent.emptyStatus")}
onChange={setStatusInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("aiAgent.query")}
</Button>
</DashboardToolbar>
@@ -521,13 +542,15 @@ export default function DashboardAIAgentsPage() {
<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>
<TableHead>{t("aiAgent.columnAiConfig")}</TableHead>
<TableHead>{t("aiAgent.columnServiceMode")}</TableHead>
<TableHead>{t("aiAgent.columnKnowledge")}</TableHead>
<TableHead>{t("aiAgent.columnSkills")}</TableHead>
<TableHead>{t("aiAgent.columnCapabilities")}</TableHead>
<TableHead>{t("aiAgent.columnStatus")}</TableHead>
<TableHead className="w-[88px] text-right">
{t("aiAgent.columnActions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -535,8 +558,8 @@ export default function DashboardAIAgentsPage() {
<DashboardTableStateRow
colSpan={9}
loading={loading}
loadingText="正在加载 AI Agent..."
emptyText="暂无 AI Agent"
loadingText={t("aiAgent.loadingRows")}
emptyText={t("aiAgent.emptyRows")}
/>
) : null}
<SortableContext
@@ -549,6 +572,7 @@ export default function DashboardAIAgentsPage() {
item={item}
disabled={sorting}
actionLoadingId={actionLoadingId}
t={t}
openEditDialog={openEditDialog}
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
@@ -18,11 +18,9 @@ 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 { useI18n } from "@/i18n/provider"
import { OptionCombobox } from "./option-combobox"
type AIConfigEditDialogProps = {
@@ -33,15 +31,19 @@ type AIConfigEditDialogProps = {
onSubmit: (payload: CreateAIConfigPayload) => Promise<void>
}
const providerOptions = getEnumOptions(AIProviderLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))
type TFunction = (key: string, values?: Record<string, string | number>) => string
const modelTypeOptions = getEnumOptions(AIModelTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))
function getProviderOptions(t: TFunction) {
return [{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") }]
}
function getModelTypeOptions(t: TFunction) {
return [
{ value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") },
{ value: String(AIModelType.Embedding), label: t("aiConfig.modelTypeEmbedding") },
{ value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") },
]
}
const emptyForm: EditForm = {
name: "",
@@ -60,29 +62,22 @@ const emptyForm: EditForm = {
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>
>
type EditForm = {
name: string
provider: string
baseUrl: string
apiKey: string
modelType: string
modelName: string
dimension: string
maxContextTokens: string
maxOutputTokens: string
timeoutMs: string
maxRetryCount: string
rpmLimit: string
tpmLimit: string
remark: string
}
function buildForm(item: AIConfig | null): EditForm {
if (!item) {
@@ -159,12 +154,35 @@ function AIConfigEditDialogBody({
onSubmit,
}: AIConfigEditDialogBodyProps) {
const formId = "ai-config-edit-form"
const t = useI18n()
const [loading, setLoading] = useState(false)
const form = useForm<
z.input<typeof aiConfigFormSchema>,
undefined,
z.output<typeof aiConfigFormSchema>
>({
const providerOptions = useMemo(() => getProviderOptions(t), [t])
const modelTypeOptions = useMemo(() => getModelTypeOptions(t), [t])
const aiConfigFormSchema = useMemo(
() =>
z.object({
name: z.string().trim().min(1, t("aiConfig.nameRequired")),
provider: z.string().trim().min(1, t("aiConfig.providerRequired")),
baseUrl: z.string().trim().min(1, t("aiConfig.baseUrlRequired")),
apiKey: z.string().trim(),
modelType: z.string().trim().min(1, t("aiConfig.modelTypeRequired")),
modelName: z.string().trim().min(1, t("aiConfig.modelNameRequired")),
dimension: z.string().trim().regex(/^\d+$/, t("aiConfig.dimensionInvalid")),
maxContextTokens: z.string().trim().regex(/^\d+$/, t("aiConfig.maxContextInvalid")),
maxOutputTokens: z.string().trim().regex(/^\d+$/, t("aiConfig.maxOutputInvalid")),
timeoutMs: z.string().trim().regex(/^\d+$/, t("aiConfig.timeoutInvalid")),
maxRetryCount: z.string().trim().regex(/^\d+$/, t("aiConfig.retryInvalid")),
rpmLimit: z.string().trim().regex(/^\d+$/, t("aiConfig.rpmInvalid")),
tpmLimit: z.string().trim().regex(/^\d+$/, t("aiConfig.tpmInvalid")),
remark: z.string().trim(),
}),
[t],
)
const editFormResolver = useMemo(
() => zodResolver(aiConfigFormSchema as never) as Resolver<EditForm>,
[aiConfigFormSchema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -206,7 +224,7 @@ function AIConfigEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑 AI 配置" : "新建 AI 配置"}
title={itemId ? t("aiConfig.editTitle") : t("aiConfig.createTitle")}
size="xl"
footer={
<>
@@ -216,26 +234,26 @@ function AIConfigEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("aiConfig.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("aiConfig.saving") : itemId ? t("aiConfig.save") : t("aiConfig.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("aiConfig.loading")}</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ai-config-name">配置名称</FieldLabel>
<FieldLabel htmlFor="ai-config-name">{t("aiConfig.name")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-name"
placeholder="例如:OpenAI 主回答模型"
placeholder={t("aiConfig.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -245,7 +263,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.provider}>
<FieldLabel>供应商</FieldLabel>
<FieldLabel>{t("aiConfig.provider")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -254,9 +272,9 @@ function AIConfigEditDialogBody({
<OptionCombobox
value={field.value}
options={providerOptions}
placeholder="请选择供应商"
searchPlaceholder="搜索供应商"
emptyText="未找到供应商"
placeholder={t("aiConfig.selectProvider")}
searchPlaceholder={t("aiConfig.searchProvider")}
emptyText={t("aiConfig.emptyProvider")}
onChange={field.onChange}
/>
)}
@@ -266,7 +284,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.modelType}>
<FieldLabel>模型类型</FieldLabel>
<FieldLabel>{t("aiConfig.modelType")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -275,9 +293,9 @@ function AIConfigEditDialogBody({
<OptionCombobox
value={field.value}
options={modelTypeOptions}
placeholder="请选择模型类型"
searchPlaceholder="搜索模型类型"
emptyText="未找到模型类型"
placeholder={t("aiConfig.selectModelType")}
searchPlaceholder={t("aiConfig.searchModelType")}
emptyText={t("aiConfig.emptyModelType")}
onChange={field.onChange}
/>
)}
@@ -288,11 +306,11 @@ function AIConfigEditDialogBody({
</div>
<Field data-invalid={!!errors.baseUrl}>
<FieldLabel htmlFor="ai-config-base-url">Base URL</FieldLabel>
<FieldLabel htmlFor="ai-config-base-url">{t("aiConfig.baseUrl")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-base-url"
placeholder="例如:https://api.openai.com/v1"
placeholder={t("aiConfig.baseUrlPlaceholder")}
aria-invalid={!!errors.baseUrl}
{...register("baseUrl")}
/>
@@ -306,7 +324,7 @@ function AIConfigEditDialogBody({
<Input
id="ai-config-api-key"
type="password"
placeholder="请输入 API Key"
placeholder={t("aiConfig.apiKeyPlaceholder")}
aria-invalid={!!errors.apiKey}
{...register("apiKey")}
/>
@@ -316,11 +334,11 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.modelName}>
<FieldLabel htmlFor="ai-config-model-name">模型名称</FieldLabel>
<FieldLabel htmlFor="ai-config-model-name">{t("aiConfig.modelName")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-model-name"
placeholder="例如:gpt-4o-mini"
placeholder={t("aiConfig.modelNamePlaceholder")}
aria-invalid={!!errors.modelName}
{...register("modelName")}
/>
@@ -329,7 +347,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.dimension}>
<FieldLabel htmlFor="ai-config-dimension">向量维度</FieldLabel>
<FieldLabel htmlFor="ai-config-dimension">{t("aiConfig.dimensionLabel")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-dimension"
@@ -347,7 +365,7 @@ function AIConfigEditDialogBody({
<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>
<FieldLabel htmlFor="ai-config-max-context">{t("aiConfig.maxContextTokens")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-max-context"
@@ -362,7 +380,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.maxOutputTokens}>
<FieldLabel htmlFor="ai-config-max-output">最大输出 Token</FieldLabel>
<FieldLabel htmlFor="ai-config-max-output">{t("aiConfig.maxOutputTokens")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-max-output"
@@ -379,7 +397,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.timeoutMs}>
<FieldLabel htmlFor="ai-config-timeout">超时时间 (ms)</FieldLabel>
<FieldLabel htmlFor="ai-config-timeout">{t("aiConfig.timeoutMs")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-timeout"
@@ -394,7 +412,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.maxRetryCount}>
<FieldLabel htmlFor="ai-config-retry">最大重试次数</FieldLabel>
<FieldLabel htmlFor="ai-config-retry">{t("aiConfig.maxRetryCount")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-retry"
@@ -411,7 +429,7 @@ function AIConfigEditDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.rpmLimit}>
<FieldLabel htmlFor="ai-config-rpm">RPM 限制</FieldLabel>
<FieldLabel htmlFor="ai-config-rpm">{t("aiConfig.rpmLimit")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-rpm"
@@ -426,7 +444,7 @@ function AIConfigEditDialogBody({
</Field>
<Field data-invalid={!!errors.tpmLimit}>
<FieldLabel htmlFor="ai-config-tpm">TPM 限制</FieldLabel>
<FieldLabel htmlFor="ai-config-tpm">{t("aiConfig.tpmLimit")}</FieldLabel>
<FieldContent>
<Input
id="ai-config-tpm"
@@ -442,11 +460,11 @@ function AIConfigEditDialogBody({
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="ai-config-remark">备注</FieldLabel>
<FieldLabel htmlFor="ai-config-remark">{t("aiConfig.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="ai-config-remark"
placeholder="记录用途、费用、限制说明等"
placeholder={t("aiConfig.remarkPlaceholder")}
rows={3}
aria-invalid={!!errors.remark}
{...register("remark")}
@@ -16,6 +16,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
export type ComboboxOption = {
@@ -37,11 +38,12 @@ export function OptionCombobox({
value,
options,
placeholder,
searchPlaceholder = "请输入关键字搜索",
emptyText = "没有可选项",
searchPlaceholder,
emptyText,
disabled = false,
onChange,
}: OptionComboboxProps) {
const t = useI18n()
const selectedLabel =
options.find((option) => option.value === value)?.label ?? placeholder
@@ -62,9 +64,9 @@ export function OptionCombobox({
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandInput placeholder={searchPlaceholder ?? t("common.searchKeyword")} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandEmpty>{emptyText ?? t("common.emptyOptions")}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
+105 -89
View File
@@ -26,7 +26,7 @@ import {
SearchIcon,
Trash2Icon
} from "lucide-react";
import { useCallback, useEffect, useState, type CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
import { toast } from "sonner";
import {
@@ -76,40 +76,52 @@ import {
} from "@/lib/api/admin";
import {
AIModelType,
AIModelTypeLabels,
AIProvider,
AIProviderLabels,
Status,
StatusLabels
} from "@/lib/generated/enums";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
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,
})),
];
type TFunction = (key: string, values?: Record<string, string | number>) => string;
const providerFilterOptions = [
{ value: "all", label: "全部供应商" },
...getEnumOptions(AIProviderLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
function getStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("aiConfig.allStatuses") },
{ value: String(Status.Ok), label: t("aiConfig.enabled") },
{ value: String(Status.Disabled), label: t("aiConfig.disabled") },
{ value: String(Status.Deleted), label: t("aiConfig.deletedStatus") },
];
}
const modelTypeFilterOptions = [
{ value: "all", label: "全部类型" },
...getEnumOptions(AIModelTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
})),
];
function getProviderOptions(t: TFunction, includeAll = true) {
const options = [
{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") },
];
return includeAll ? [{ value: "all", label: t("aiConfig.allProviders") }, ...options] : options;
}
function getModelTypeOptions(t: TFunction, includeAll = true) {
const options = [
{ value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") },
{ value: String(AIModelType.Embedding), label: t("aiConfig.modelTypeEmbedding") },
{ value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") },
];
return includeAll ? [{ value: "all", label: t("aiConfig.allTypes") }, ...options] : options;
}
function getStatusLabel(value: Status, t: TFunction) {
return getStatusOptions(t).find((item) => item.value === String(value))?.label ?? String(value);
}
function getProviderLabel(value: AIProvider, t: TFunction) {
return getProviderOptions(t, false).find((item) => item.value === String(value))?.label ?? String(value);
}
function getModelTypeLabel(value: AIModelType, t: TFunction) {
return getModelTypeOptions(t, false).find((item) => item.value === String(value))?.label ?? String(value);
}
function maskAPIKey(value: string) {
const text = value.trim();
@@ -126,6 +138,7 @@ type SortableAIConfigRowProps = {
item: AIConfig;
disabled: boolean;
actionLoadingId: number | null;
t: TFunction;
openEditDialog: (item: AIConfig) => void;
handleToggleStatus: (item: AIConfig) => void;
handleDelete: (item: AIConfig) => void;
@@ -135,6 +148,7 @@ function SortableAIConfigRow({
item,
disabled,
actionLoadingId,
t,
openEditDialog,
handleToggleStatus,
handleDelete,
@@ -172,7 +186,7 @@ function SortableAIConfigRow({
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
aria-label={t("aiConfig.dragSort", { name: item.name })}
{...attributes}
{...listeners}
>
@@ -184,24 +198,18 @@ function SortableAIConfigRow({
</TableCell>
<TableCell>
<Badge variant="outline">
{getEnumLabel(
AIProviderLabels,
item.provider as AIProvider,
)}
{getProviderLabel(item.provider as AIProvider, t)}
</Badge>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant="secondary">
{getEnumLabel(
AIModelTypeLabels,
item.modelType as AIModelType,
)}
{getModelTypeLabel(item.modelType as AIModelType, t)}
</Badge>
<div className="text-sm">{item.modelName}</div>
{item.dimension > 0 && (
<div className="text-xs text-muted-foreground">
{item.dimension} 维
{t("aiConfig.dimension", { count: item.dimension })}
</div>
)}
</div>
@@ -210,16 +218,19 @@ function SortableAIConfigRow({
<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)}
{t("aiConfig.apiKey", { 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>{t("aiConfig.contextTokens", { count: item.maxContextTokens || 0 })}</div>
<div>{t("aiConfig.outputTokens", { count: item.maxOutputTokens || 0 })}</div>
<div>
超时 {item.timeoutMs}ms / 重试 {item.maxRetryCount}
{t("aiConfig.timeoutRetry", {
timeout: item.timeoutMs,
retries: item.maxRetryCount,
})}
</div>
<div>
RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0}
@@ -232,17 +243,14 @@ function SortableAIConfigRow({
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("aiConfig.toggleStatus", { name: item.name })}
/>
<Badge
variant={
item.status === Status.Ok ? "default" : "outline"
}
>
{getEnumLabel(
StatusLabels,
item.status as keyof typeof StatusLabels,
)}
{getStatusLabel(item.status as Status, t)}
</Badge>
</div>
</TableCell>
@@ -253,12 +261,12 @@ function SortableAIConfigRow({
size="sm"
onClick={() => openEditDialog(item)}
>
编辑
{t("aiConfig.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
aria-label={t("aiConfig.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -273,10 +281,10 @@ function SortableAIConfigRow({
>
<Trash2Icon />
{item.status === Status.Ok
? "启用中不可删"
? t("aiConfig.deleteDisabledActive")
: actionLoadingId === item.id
? "删除中..."
: "删除"}
? t("aiConfig.deleting")
: t("aiConfig.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -287,6 +295,10 @@ function SortableAIConfigRow({
}
export default function DashboardAIConfigsPage() {
const t = useI18n();
const listStatusOptions = useMemo(() => getStatusOptions(t), [t]);
const providerFilterOptions = useMemo(() => getProviderOptions(t), [t]);
const modelTypeFilterOptions = useMemo(() => getModelTypeOptions(t), [t]);
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [providerFilterInput, setProviderFilterInput] = useState("all");
@@ -335,11 +347,11 @@ export default function DashboardAIConfigsPage() {
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI 配置失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.loadFailed"));
} finally {
setLoading(false);
}
}, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit]);
}, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit, t]);
useEffect(() => {
void loadData();
@@ -405,16 +417,16 @@ export default function DashboardAIConfigsPage() {
try {
if (editingItem) {
await updateAIConfig({ id: editingItem.id, ...payload });
toast.success(`已更新 AI 配置:${editingItem.name}`);
toast.success(t("aiConfig.updated", { name: editingItem.name }));
} else {
await createAIConfig(payload);
toast.success(`已创建 AI 配置:${payload.name}`);
toast.success(t("aiConfig.created", { name: payload.name }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存 AI 配置失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.saveFailed"));
} finally {
setSaving(false);
}
@@ -429,11 +441,14 @@ export default function DashboardAIConfigsPage() {
: Status.Ok;
await updateAIConfigStatus(item.id, nextStatus);
toast.success(
`已${nextStatus === Status.Ok ? "启用" : "禁用"}:${item.name}`,
t("aiConfig.statusChanged", {
name: item.name,
status: nextStatus === Status.Ok ? t("aiConfig.enabled") : t("aiConfig.disabled"),
}),
);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.statusUpdateFailed"));
} finally {
setActionLoadingId(null);
}
@@ -441,7 +456,7 @@ export default function DashboardAIConfigsPage() {
async function handleDelete(item: AIConfig) {
if (item.status === Status.Ok) {
toast.error("启用中的 AI 配置不允许删除");
toast.error(t("aiConfig.activeDeleteBlocked"));
return;
}
setDeletingItem(item);
@@ -456,12 +471,12 @@ export default function DashboardAIConfigsPage() {
setActionLoadingId(item.id);
try {
await deleteAIConfig(item.id);
toast.success(`已删除 AI 配置:${item.name}`);
toast.success(t("aiConfig.deleted", { name: item.name }));
setDeleteDialogOpen(false);
setDeletingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除 AI 配置失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.deleteFailed"));
} finally {
setActionLoadingId(null);
}
@@ -489,14 +504,14 @@ export default function DashboardAIConfigsPage() {
try {
await updateAIConfigSort(nextResults.map((item) => item.id));
toast.success("AI 配置排序已更新");
toast.success(t("aiConfig.sortUpdated"));
await loadData();
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}));
toast.error(error instanceof Error ? error.message : "更新排序失败");
toast.error(error instanceof Error ? error.message : t("aiConfig.sortUpdateFailed"));
} finally {
setSorting(false);
}
@@ -514,11 +529,11 @@ export default function DashboardAIConfigsPage() {
disabled={loading}
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
刷新
{t("aiConfig.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
新建
{t("aiConfig.new")}
</Button>
</>
}
@@ -529,7 +544,7 @@ export default function DashboardAIConfigsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按配置名称筛选"
placeholder={t("aiConfig.filterName")}
className="pl-9"
/>
</div>
@@ -537,9 +552,9 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox
value={modelTypeFilterInput}
options={modelTypeFilterOptions}
placeholder="全部类型"
searchPlaceholder="搜索模型类型"
emptyText="未找到模型类型"
placeholder={t("aiConfig.allTypes")}
searchPlaceholder={t("aiConfig.searchModelType")}
emptyText={t("aiConfig.emptyModelType")}
onChange={setModelTypeFilterInput}
/>
</div>
@@ -547,9 +562,9 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox
value={providerFilterInput}
options={providerFilterOptions}
placeholder="全部供应商"
searchPlaceholder="搜索供应商"
emptyText="未找到供应商"
placeholder={t("aiConfig.allProviders")}
searchPlaceholder={t("aiConfig.searchProvider")}
emptyText={t("aiConfig.emptyProvider")}
onChange={setProviderFilterInput}
/>
</div>
@@ -557,15 +572,15 @@ export default function DashboardAIConfigsPage() {
<OptionCombobox
value={statusFilterInput}
options={listStatusOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("aiConfig.allStatuses")}
searchPlaceholder={t("aiConfig.searchStatus")}
emptyText={t("aiConfig.emptyStatus")}
onChange={setStatusFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("aiConfig.query")}
</Button>
</DashboardToolbar>
@@ -589,13 +604,13 @@ export default function DashboardAIConfigsPage() {
<TableHeader>
<TableRow>
<TableHead className="w-14"></TableHead>
<TableHead>配置</TableHead>
<TableHead>供应商</TableHead>
<TableHead>模型</TableHead>
<TableHead>接入信息</TableHead>
<TableHead>限制</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
<TableHead>{t("aiConfig.columnConfig")}</TableHead>
<TableHead>{t("aiConfig.columnProvider")}</TableHead>
<TableHead>{t("aiConfig.columnModel")}</TableHead>
<TableHead>{t("aiConfig.columnAccess")}</TableHead>
<TableHead>{t("aiConfig.columnLimits")}</TableHead>
<TableHead>{t("aiConfig.columnStatus")}</TableHead>
<TableHead className="text-right">{t("aiConfig.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -603,8 +618,8 @@ export default function DashboardAIConfigsPage() {
<DashboardTableStateRow
colSpan={8}
loading={loading}
loadingText="正在加载 AI 配置..."
emptyText="暂无 AI 配置数据"
loadingText={t("aiConfig.loadingRows")}
emptyText={t("aiConfig.emptyRows")}
/>
) : (
<SortableContext
@@ -617,6 +632,7 @@ export default function DashboardAIConfigsPage() {
item={item}
disabled={sorting}
actionLoadingId={actionLoadingId}
t={t}
openEditDialog={openEditDialog}
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
@@ -652,11 +668,11 @@ export default function DashboardAIConfigsPage() {
>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>确认删除 AI 配置</DialogTitle>
<DialogTitle>{t("aiConfig.confirmDeleteTitle")}</DialogTitle>
<DialogDescription>
{deletingItem
? `确认删除“${deletingItem.name}”吗?此操作不可撤销。`
: "此操作不可撤销。"}
? t("aiConfig.confirmDeleteDescription", { name: deletingItem.name })
: t("aiConfig.deleteIrreversible")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -669,7 +685,7 @@ export default function DashboardAIConfigsPage() {
setDeletingItem(null);
}}
>
取消
{t("aiConfig.cancel")}
</Button>
<Button
type="button"
@@ -677,7 +693,7 @@ export default function DashboardAIConfigsPage() {
disabled={!!actionLoadingId}
onClick={() => void handleConfirmDelete()}
>
{actionLoadingId ? "删除中..." : "确认删除"}
{actionLoadingId ? t("aiConfig.deleting") : t("aiConfig.confirmDelete")}
</Button>
</DialogFooter>
</DialogContent>
+175 -143
View File
@@ -28,6 +28,7 @@ import {
fetchWxWorkKFAccounts,
resetChannelUserTokenSecret,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type ChannelFormDialogProps = {
open: boolean
@@ -37,16 +38,7 @@ type ChannelFormDialogProps = {
onSubmit: (payload: CreateAdminChannelPayload) => Promise<void>
}
const channelTypeOptions = [
{ value: "web", label: "Web 站点" },
{ value: "wechat_mp", label: "微信公众号" },
{ value: "wxwork_kf", label: "企业微信客服" },
] as const
const widgetPositionOptions = [
{ value: "right", label: "右下角" },
{ value: "left", label: "左下角" },
] as const
type Translate = (key: string, values?: Record<string, string | number>) => string
type WebChannelConfig = {
title?: string
@@ -64,59 +56,72 @@ type WechatMPChannelConfig = {
userTokenSecret?: string
}
const defaultWebChannelConfig: Required<WebChannelConfig> = {
title: "在线客服",
subtitle: "欢迎咨询",
themeColor: "#2563eb",
position: "right",
width: "380px",
userTokenSecret: "",
function getDefaultWebChannelConfig(t: Translate): Required<WebChannelConfig> {
return {
title: t("channel.defaultTitleWeb"),
subtitle: t("channel.defaultSubtitle"),
themeColor: "#2563eb",
position: "right",
width: "380px",
userTokenSecret: "",
}
}
const schema = z
.object({
channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], "请选择渠道类型"),
aiAgentId: z.string().trim().regex(/^\d+$/, "请选择 AI Agent"),
name: z.string().trim().min(1, "渠道名称不能为空"),
openKfId: z.string().trim(),
widgetTitle: z.string().trim(),
widgetSubtitle: z.string().trim(),
widgetThemeColor: z.string().trim(),
widgetPosition: z.enum(["left", "right"]),
widgetWidth: z.string().trim(),
userTokenSecret: z.string().trim(),
remark: z.string().trim(),
})
.superRefine((values, ctx) => {
if (values.channelType === "wxwork_kf" && !values.openKfId.trim()) {
ctx.addIssue({
code: "custom",
path: ["openKfId"],
message: "请选择企业微信客服账号",
})
}
})
function createSchema(t: Translate) {
return z
.object({
channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], t("channel.typeRequired")),
aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")),
name: z.string().trim().min(1, t("channel.nameRequired")),
openKfId: z.string().trim(),
widgetTitle: z.string().trim(),
widgetSubtitle: z.string().trim(),
widgetThemeColor: z.string().trim(),
widgetPosition: z.enum(["left", "right"]),
widgetWidth: z.string().trim(),
userTokenSecret: z.string().trim(),
remark: z.string().trim(),
})
.superRefine((values, ctx) => {
if (values.channelType === "wxwork_kf" && !values.openKfId.trim()) {
ctx.addIssue({
code: "custom",
path: ["openKfId"],
message: t("channel.wxworkAccountRequired"),
})
}
})
}
type EditForm = z.infer<typeof schema>
type EditForm = {
channelType: "web" | "wechat_mp" | "wxwork_kf"
aiAgentId: string
name: string
openKfId: string
widgetTitle: string
widgetSubtitle: string
widgetThemeColor: string
widgetPosition: "left" | "right"
widgetWidth: string
userTokenSecret: string
remark: string
}
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: "",
widgetTitle: defaultWebChannelConfig.title,
widgetSubtitle: defaultWebChannelConfig.subtitle,
widgetThemeColor: defaultWebChannelConfig.themeColor,
widgetPosition: defaultWebChannelConfig.position,
widgetWidth: defaultWebChannelConfig.width,
userTokenSecret: "",
remark: "",
function createEmptyForm(t: Translate): EditForm {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
return {
channelType: "web",
aiAgentId: "",
name: "",
openKfId: "",
widgetTitle: defaultWebChannelConfig.title,
widgetSubtitle: defaultWebChannelConfig.subtitle,
widgetThemeColor: defaultWebChannelConfig.themeColor,
widgetPosition: defaultWebChannelConfig.position,
widgetWidth: defaultWebChannelConfig.width,
userTokenSecret: "",
remark: "",
}
}
function parseOpenKfId(configJson: string): string {
@@ -131,7 +136,8 @@ function parseOpenKfId(configJson: string): string {
}
}
function parseWebChannelConfig(configJson: string): Required<WebChannelConfig> {
function parseWebChannelConfig(configJson: string, t: Translate): Required<WebChannelConfig> {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
if (!configJson.trim()) {
return defaultWebChannelConfig
}
@@ -152,9 +158,10 @@ function parseWebChannelConfig(configJson: string): Required<WebChannelConfig> {
}
}
function parseWechatMPChannelConfig(configJson: string): Required<WechatMPChannelConfig> {
function parseWechatMPChannelConfig(configJson: string, t: Translate): Required<WechatMPChannelConfig> {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
const fallback = {
title: "公众号客服",
title: t("channel.defaultTitleWechat"),
subtitle: defaultWebChannelConfig.subtitle,
themeColor: defaultWebChannelConfig.themeColor,
userTokenSecret: "",
@@ -176,14 +183,14 @@ function parseWechatMPChannelConfig(configJson: string): Required<WechatMPChanne
}
}
function buildForm(item: AdminChannel | null): EditForm {
function buildForm(item: AdminChannel | null, t: Translate): EditForm {
if (!item) {
return emptyForm
return createEmptyForm(t)
}
const isWechatMP = item.channelType === "wechat_mp"
const webConfig = parseWebChannelConfig(item.configJson)
const webConfig = parseWebChannelConfig(item.configJson, t)
const wechatConfig = isWechatMP
? parseWechatMPChannelConfig(item.configJson)
? parseWechatMPChannelConfig(item.configJson, t)
: null
return {
channelType:
@@ -205,12 +212,13 @@ function buildForm(item: AdminChannel | null): EditForm {
}
}
function buildPayload(form: EditForm, status: number): CreateAdminChannelPayload {
function buildPayload(form: EditForm, status: number, t: Translate): CreateAdminChannelPayload {
const channelType = form.channelType
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
const webLikeConfig = {
title:
form.widgetTitle.trim() ||
(channelType === "wechat_mp" ? "公众号客服" : defaultWebChannelConfig.title),
(channelType === "wechat_mp" ? t("channel.defaultTitleWechat") : defaultWebChannelConfig.title),
subtitle: form.widgetSubtitle.trim(),
themeColor:
form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor,
@@ -267,7 +275,19 @@ function ChannelFormBody({
onOpenChange,
onSubmit,
}: ChannelFormBodyProps) {
const t = useI18n()
const formId = "channel-edit-form"
const emptyForm = useMemo(() => createEmptyForm(t), [t])
const schema = useMemo(() => createSchema(t), [t])
const resolver = useMemo(
() =>
zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>,
[schema],
)
const [loading, setLoading] = useState(false)
const [aiAgents, setAIAgents] = useState<AIAgent[]>([])
const [wxWorkKFAccounts, setWxWorkKFAccounts] = useState<WxWorkKFAccount[]>([])
@@ -320,7 +340,7 @@ function ChannelFormBody({
const data = await fetchChannel(itemId)
setChannelDetail(data)
setCurrentStatus(data.status)
reset(buildForm(data))
reset(buildForm(data, t))
} catch (error) {
console.error("Failed to load channel:", error)
} finally {
@@ -328,7 +348,7 @@ function ChannelFormBody({
}
}
void loadDetail()
}, [itemId, reset])
}, [emptyForm, itemId, reset, t])
useEffect(() => {
if (
@@ -348,7 +368,7 @@ function ChannelFormBody({
} catch (error) {
console.error("Failed to load WeCom KF accounts:", error)
setWxWorkKFAccountsError(
error instanceof Error ? error.message : "企业微信客服账号加载失败"
error instanceof Error ? error.message : t("channel.loadWxworkAccountsFailed")
)
} finally {
setWxWorkKFAccountsLoading(false)
@@ -360,6 +380,7 @@ function ChannelFormBody({
wxWorkKFAccounts.length,
wxWorkKFAccountsError,
wxWorkKFAccountsLoading,
t,
])
const aiAgentOptions = aiAgents.map((item) => ({
@@ -370,6 +391,15 @@ function ChannelFormBody({
value: item.openKfId,
label: item.name ? `${item.name} (${item.openKfId})` : item.openKfId,
}))
const channelTypeOptions = [
{ value: "web", label: t("channel.typeWeb") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
{ value: "wxwork_kf", label: t("channel.typeWxworkKf") },
] as const
const widgetPositionOptions = [
{ value: "right", label: t("channel.positionRight") },
{ value: "left", label: t("channel.positionLeft") },
] as const
if (
channelType === "wxwork_kf" &&
openKfId &&
@@ -382,14 +412,14 @@ function ChannelFormBody({
}
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, currentStatus))
await onSubmit(buildPayload(values, currentStatus, t))
}
async function handleResetUserTokenSecret() {
if (!itemId) {
return
}
if (!window.confirm("重置后旧 userToken 将在过期后失效,确认重置?")) {
if (!window.confirm(t("channel.resetSecretConfirm"))) {
return
}
try {
@@ -405,9 +435,9 @@ function ChannelFormBody({
configJson: JSON.stringify(parsed),
})
}
toast.success("已重置用户 JWT Secret")
toast.success(t("channel.resetSecretSuccess"))
} catch (error) {
toast.error(error instanceof Error ? error.message : "重置用户 JWT Secret 失败")
toast.error(error instanceof Error ? error.message : t("channel.resetSecretFailed"))
}
}
@@ -417,9 +447,9 @@ function ChannelFormBody({
}
try {
await navigator.clipboard.writeText(userTokenSecret)
toast.success("已复制用户 JWT Secret")
toast.success(t("channel.copySecretSuccess"))
} catch {
toast.error("复制失败")
toast.error(t("channel.copyFailed"))
}
}
@@ -427,29 +457,29 @@ function ChannelFormBody({
<ProjectDialog
open={true}
onOpenChange={onOpenChange}
title={itemId ? "编辑渠道" : "新建渠道"}
title={itemId ? t("channel.editTitle") : t("channel.createTitle")}
size="lg"
allowFullscreen
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
{t("channel.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("channel.saving") : t("channel.save")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("channel.loadingDetail")}</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-5">
<div className="grid grid-cols-1 gap-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="channel-name">渠道名称</FieldLabel>
<FieldLabel htmlFor="channel-name">{t("channel.name")}</FieldLabel>
<FieldContent>
<Input id="channel-name" {...register("name")} />
<FieldError errors={[errors.name]} />
@@ -457,7 +487,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.aiAgentId}>
<FieldLabel>接待 Agent</FieldLabel>
<FieldLabel>{t("channel.columnAgent")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -466,9 +496,9 @@ function ChannelFormBody({
<OptionCombobox
value={field.value}
options={aiAgentOptions}
placeholder="请选择 AI Agent"
searchPlaceholder="搜索 AI Agent"
emptyText="未找到 AI Agent"
placeholder={t("channel.agentRequired")}
searchPlaceholder={t("channel.searchAiAgent")}
emptyText={t("channel.emptyAiAgent")}
onChange={field.onChange}
/>
)}
@@ -478,7 +508,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.channelType}>
<FieldLabel>接入渠道</FieldLabel>
<FieldLabel>{t("channel.channelType")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -487,9 +517,9 @@ function ChannelFormBody({
<OptionCombobox
value={field.value}
options={[...channelTypeOptions]}
placeholder="请选择接入渠道"
searchPlaceholder="搜索接入渠道"
emptyText="未找到接入渠道"
placeholder={t("channel.selectChannelType")}
searchPlaceholder={t("channel.searchChannelType")}
emptyText={t("channel.emptyChannelType")}
onChange={field.onChange}
/>
)}
@@ -501,19 +531,19 @@ function ChannelFormBody({
<div className="space-y-4 rounded-md border p-4">
<div>
<div className="text-sm font-medium">渠道配置</div>
<div className="text-sm font-medium">{t("channel.configTitle")}</div>
<div className="text-xs text-muted-foreground">
{channelType === "wxwork_kf"
? "配置企业微信客服账号,用于匹配回调消息和对外发送消息。"
? t("channel.configWxworkDescription")
: channelType === "wechat_mp"
? "配置公众号菜单直达聊天页的展示参数。"
: "配置 Web 站点客服窗口的展示参数。"}
? t("channel.configWechatDescription")
: t("channel.configWebDescription")}
</div>
</div>
{channelType === "wxwork_kf" ? (
<Field data-invalid={!!errors.openKfId}>
<FieldLabel>企业微信客服账号</FieldLabel>
<FieldLabel>{t("channel.wxworkAccount")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -523,11 +553,11 @@ function ChannelFormBody({
value={field.value}
options={wxWorkKFAccountOptions}
placeholder={
wxWorkKFAccountsLoading ? "正在加载客服账号" : "请选择客服账号"
wxWorkKFAccountsLoading ? t("channel.loadingWxworkAccount") : t("channel.selectWxworkAccount")
}
searchPlaceholder="搜索客服账号"
searchPlaceholder={t("channel.searchWxworkAccount")}
emptyText={
wxWorkKFAccountsError || "未找到企业微信客服账号"
wxWorkKFAccountsError || t("channel.emptyWxworkAccount")
}
disabled={wxWorkKFAccountsLoading}
onChange={field.onChange}
@@ -543,7 +573,7 @@ function ChannelFormBody({
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.widgetTitle}>
<FieldLabel htmlFor="channel-widget-title">窗口标题</FieldLabel>
<FieldLabel htmlFor="channel-widget-title">{t("channel.widgetTitle")}</FieldLabel>
<FieldContent>
<Input id="channel-widget-title" {...register("widgetTitle")} />
<FieldError errors={[errors.widgetTitle]} />
@@ -551,7 +581,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.widgetSubtitle}>
<FieldLabel htmlFor="channel-widget-subtitle">窗口副标题</FieldLabel>
<FieldLabel htmlFor="channel-widget-subtitle">{t("channel.widgetSubtitle")}</FieldLabel>
<FieldContent>
<Input
id="channel-widget-subtitle"
@@ -562,7 +592,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.widgetThemeColor}>
<FieldLabel htmlFor="channel-widget-theme-color">主题色</FieldLabel>
<FieldLabel htmlFor="channel-widget-theme-color">{t("channel.themeColor")}</FieldLabel>
<FieldContent>
<Input
id="channel-widget-theme-color"
@@ -576,7 +606,7 @@ function ChannelFormBody({
{channelType === "web" ? (
<>
<Field data-invalid={!!errors.widgetPosition}>
<FieldLabel>挂载位置</FieldLabel>
<FieldLabel>{t("channel.mountPosition")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -585,9 +615,9 @@ function ChannelFormBody({
<OptionCombobox
value={field.value}
options={[...widgetPositionOptions]}
placeholder="请选择挂载位置"
searchPlaceholder="搜索挂载位置"
emptyText="未找到挂载位置"
placeholder={t("channel.selectMountPosition")}
searchPlaceholder={t("channel.searchMountPosition")}
emptyText={t("channel.emptyMountPosition")}
onChange={field.onChange}
/>
)}
@@ -597,7 +627,7 @@ function ChannelFormBody({
</Field>
<Field data-invalid={!!errors.widgetWidth}>
<FieldLabel htmlFor="channel-widget-width">窗口宽度</FieldLabel>
<FieldLabel htmlFor="channel-widget-width">{t("channel.widgetWidth")}</FieldLabel>
<FieldContent>
<Input
id="channel-widget-width"
@@ -612,14 +642,14 @@ function ChannelFormBody({
</div>
<div className="space-y-3 rounded-md border p-3">
<div>
<div className="text-sm font-medium">用户 JWT Secret</div>
<div className="text-sm font-medium">{t("channel.userJwtSecret")}</div>
<div className="text-xs text-muted-foreground">
业务系统使用该 secret 签发 userToken。重置后请同步更新业务系统配置。
{t("channel.userJwtSecretDescription")}
</div>
</div>
{!itemId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
保存渠道后可生成用户 JWT Secret。
{t("channel.secretAfterSave")}
</div>
) : (
<Field data-invalid={!!errors.userTokenSecret}>
@@ -640,14 +670,14 @@ function ChannelFormBody({
disabled={!userTokenSecret}
>
<CopyIcon className="size-4" />
复制
{t("channel.copy")}
</Button>
<Button
type="button"
variant="outline"
onClick={() => void handleResetUserTokenSecret()}
>
重置
{t("channel.reset")}
</Button>
</div>
</div>
@@ -666,7 +696,7 @@ function ChannelFormBody({
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="channel-remark">备注</FieldLabel>
<FieldLabel htmlFor="channel-remark">{t("channel.remark")}</FieldLabel>
<FieldContent>
<Textarea id="channel-remark" rows={3} {...register("remark")} />
<FieldError errors={[errors.remark]} />
@@ -679,6 +709,7 @@ function ChannelFormBody({
}
function WebAccessGuide({ channelId }: { channelId: string }) {
const t = useI18n()
const [origin, setOrigin] = useState("")
useEffect(() => {
@@ -723,29 +754,29 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
await navigator.clipboard.writeText(text)
toast.success(successMessage)
} catch {
toast.error("复制失败")
toast.error(t("channel.copyFailed"))
}
}
return (
<div className="space-y-4 border-t pt-4">
<div>
<div className="text-sm font-medium">Web 接入信息</div>
<div className="text-sm font-medium">{t("channel.webAccessInfo")}</div>
<div className="text-xs text-muted-foreground">
{channelId
? "复制链接或嵌入代码即可接入当前 Web 渠道。"
: "保存渠道后生成接入链接。"}
? t("channel.webAccessReady")
: t("channel.webAccessPending")}
</div>
</div>
{!channelId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
当前为新建渠道,保存后会生成 channelId。
{t("channel.newChannelPending")}
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">直接访问链接</div>
<div className="text-xs font-medium text-muted-foreground">{t("channel.directAccessUrl")}</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={accessUrl} className="font-mono text-xs" />
<div className="flex gap-2">
@@ -753,8 +784,8 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
type="button"
variant="outline"
size="icon"
title="复制链接"
onClick={() => copyText(accessUrl, "已复制接入链接")}
title={t("channel.copyLink")}
onClick={() => copyText(accessUrl, t("channel.copiedAccessLink"))}
>
<CopyIcon className="size-4" />
</Button>
@@ -762,7 +793,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
type="button"
variant="outline"
size="icon"
title="打开链接"
title={t("channel.openLink")}
onClick={() => window.open(accessUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLinkIcon className="size-4" />
@@ -774,16 +805,16 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium text-muted-foreground">
嵌入式接入代码
{t("channel.embeddedSnippet")}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyText(snippet, "已复制接入代码")}
onClick={() => copyText(snippet, t("channel.copiedSnippet"))}
>
<CopyIcon className="size-4" />
复制代码
{t("channel.copyCode")}
</Button>
</div>
<pre className="max-h-48 overflow-auto rounded-md bg-muted p-3 text-xs leading-5">
@@ -792,11 +823,11 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
</div>
<div className="flex flex-col gap-2 rounded-md bg-muted px-3 py-3 text-xs text-muted-foreground">
<div className="font-medium text-foreground">接入教程</div>
<div>1. 确认该渠道已启用。</div>
<div>2. 将嵌入代码粘贴到目标网站 HTML 的 body 结束标签前。</div>
<div>3. 发布网站后刷新页面,客服入口会按渠道配置展示。</div>
<div>4. 独立页面或二维码场景可直接使用访问链接。</div>
<div className="font-medium text-foreground">{t("channel.accessGuide")}</div>
<div>{t("channel.webGuide1")}</div>
<div>{t("channel.webGuide2")}</div>
<div>{t("channel.webGuide3")}</div>
<div>{t("channel.webGuide4")}</div>
<div className="pt-1">
<Button
type="button"
@@ -805,7 +836,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
onClick={() => window.open(testUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLinkIcon className="size-4" />
打开测试页
{t("channel.openTestPage")}
</Button>
</div>
</div>
@@ -816,6 +847,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
}
function WechatMPAccessGuide({ channelId }: { channelId: string }) {
const t = useI18n()
const [origin, setOrigin] = useState("")
useEffect(() => {
@@ -837,32 +869,32 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
}
try {
await navigator.clipboard.writeText(text)
toast.success("已复制公众号菜单链接")
toast.success(t("channel.copiedWechatMenuUrl"))
} catch {
toast.error("复制失败")
toast.error(t("channel.copyFailed"))
}
}
return (
<div className="space-y-4 border-t pt-4">
<div>
<div className="text-sm font-medium">微信公众号接入信息</div>
<div className="text-sm font-medium">{t("channel.wechatAccessInfo")}</div>
<div className="text-xs text-muted-foreground">
{channelId
? "将该链接配置到微信公众号自定义菜单,用户点击菜单后直接进入客服聊天页。"
: "保存渠道后生成公众号菜单链接。"}
? t("channel.wechatAccessReady")
: t("channel.wechatAccessPending")}
</div>
</div>
{!channelId ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
当前为新建渠道,保存后会生成 channelId。
{t("channel.newChannelPending")}
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
公众号菜单链接
{t("channel.wechatMenuUrl")}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={menuUrl} className="font-mono text-xs" />
@@ -871,7 +903,7 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
type="button"
variant="outline"
size="icon"
title="复制链接"
title={t("channel.copyLink")}
onClick={() => copyText(menuUrl)}
>
<CopyIcon className="size-4" />
@@ -880,7 +912,7 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
type="button"
variant="outline"
size="icon"
title="打开链接"
title={t("channel.openLink")}
onClick={() => window.open(menuUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLinkIcon className="size-4" />
@@ -890,10 +922,10 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
</div>
<div className="flex flex-col gap-2 rounded-md bg-muted px-3 py-3 text-xs text-muted-foreground">
<div className="font-medium text-foreground">接入教程</div>
<div>1. 确认该渠道已启用。</div>
<div>2. 将公众号自定义菜单跳转地址设置为上方链接。</div>
<div>3. 用户点击菜单后会直接进入客服聊天页。</div>
<div className="font-medium text-foreground">{t("channel.accessGuide")}</div>
<div>{t("channel.webGuide1")}</div>
<div>{t("channel.wechatGuide2")}</div>
<div>{t("channel.wechatGuide3")}</div>
</div>
</div>
)}
+66 -54
View File
@@ -50,33 +50,29 @@ import {
TableRow,
} from "@/components/ui/table"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { getEnumOptions } from "@/lib/enums"
import { ButtonGroup } from "@/components/ui/button-group"
import { Switch } from "@/components/ui/switch"
import { useI18n } from "@/i18n/provider"
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: "wechat_mp", label: "微信公众号" },
{ value: "wxwork_kf", label: "企业微信客服" },
] as const
function getChannelTypeLabel(channelType: string) {
function getChannelTypeLabel(channelType: string, t: (key: string) => string) {
if (channelType === "wechat_mp") {
return "微信公众号"
return t("channel.typeWechatMp")
}
if (channelType === "wxwork_kf") {
return "企业微信客服"
return t("channel.typeWxworkKf")
}
return "Web 站点"
return t("channel.typeWeb")
}
function getStatusLabel(status: Status, t: (key: string) => string) {
if (status === Status.Disabled) {
return t("status.disabled")
}
if (status === Status.Deleted) {
return t("status.deleted")
}
return t("status.ok")
}
function ChannelIcon({ channelType }: { channelType: string }) {
@@ -90,6 +86,7 @@ function ChannelIcon({ channelType }: { channelType: string }) {
}
export default function DashboardChannelsPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("")
const [channelIdInput, setChannelIdInput] = useState("")
const [channelTypeInput, setChannelTypeInput] = useState("all")
@@ -123,11 +120,26 @@ export default function DashboardChannelsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载接入渠道失败")
toast.error(error instanceof Error ? error.message : t("channel.loadFailed"))
} finally {
setLoading(false)
}
}, [channelId, channelType, limit, name, page, status])
}, [channelId, channelType, limit, name, page, status, t])
const statusOptions = [
{ value: "all", label: t("status.all") },
...getEnumOptions(StatusLabels).map((option) => ({
value: String(option.value),
label: getStatusLabel(option.value as Status, t),
})),
]
const channelTypeOptions = [
{ value: "all", label: t("channel.allTypes") },
{ value: "web", label: t("channel.typeWeb") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
{ value: "wxwork_kf", label: t("channel.typeWxworkKf") },
]
useEffect(() => {
void loadData()
@@ -167,16 +179,16 @@ export default function DashboardChannelsPage() {
try {
if (editingItem) {
await updateChannel({ id: editingItem.id, ...payload })
toast.success(`已更新接入渠道:${payload.name}`)
toast.success(t("channel.updated", { name: payload.name }))
} else {
const created = await createChannel(payload)
toast.success(`已创建接入渠道:${created.name}`)
toast.success(t("channel.created", { name: created.name }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存接入渠道失败")
toast.error(error instanceof Error ? error.message : t("channel.saveFailed"))
} finally {
setSaving(false)
}
@@ -187,10 +199,10 @@ export default function DashboardChannelsPage() {
try {
const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok
await updateChannelStatus(item.id, nextStatus)
toast.success(`已${nextStatus === Status.Ok ? "启用" : "禁用"}:${item.name}`)
toast.success(t(nextStatus === Status.Ok ? "channel.statusEnabled" : "channel.statusDisabled", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新渠道状态失败")
toast.error(error instanceof Error ? error.message : t("channel.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -200,10 +212,10 @@ export default function DashboardChannelsPage() {
setActionLoadingId(item.id)
try {
await deleteChannel(item.id)
toast.success(`已删除接入渠道:${item.name}`)
toast.success(t("channel.deleted", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除接入渠道失败")
toast.error(error instanceof Error ? error.message : t("channel.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -217,11 +229,11 @@ export default function DashboardChannelsPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
刷新
{t("channel.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
新建渠道
{t("channel.new")}
</Button>
</>
}
@@ -230,7 +242,7 @@ export default function DashboardChannelsPage() {
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按渠道名称筛选"
placeholder={t("channel.filterName")}
className="w-full sm:w-56"
/>
<div className="relative w-full sm:w-72">
@@ -239,7 +251,7 @@ export default function DashboardChannelsPage() {
value={channelIdInput}
onChange={(event) => setChannelIdInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按 channelId 筛选"
placeholder={t("channel.filterChannelId")}
className="pl-9"
/>
</div>
@@ -247,9 +259,9 @@ export default function DashboardChannelsPage() {
<OptionCombobox
value={channelTypeInput}
options={[...channelTypeOptions]}
placeholder="全部类型"
searchPlaceholder="搜索渠道类型"
emptyText="未找到渠道类型"
placeholder={t("channel.allTypes")}
searchPlaceholder={t("channel.searchType")}
emptyText={t("channel.emptyType")}
onChange={setChannelTypeInput}
/>
</div>
@@ -257,15 +269,15 @@ export default function DashboardChannelsPage() {
<OptionCombobox
value={statusInput}
options={[...statusOptions]}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("status.all")}
searchPlaceholder={t("channel.searchStatus")}
emptyText={t("channel.emptyStatus")}
onChange={setStatusInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("channel.query")}
</Button>
</DashboardToolbar>
@@ -287,12 +299,12 @@ export default function DashboardChannelsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>渠道</TableHead>
<TableHead>类型</TableHead>
<TableHead>{t("channel.columnChannel")}</TableHead>
<TableHead>{t("channel.columnType")}</TableHead>
<TableHead>ChannelID</TableHead>
<TableHead>接待 Agent</TableHead>
<TableHead>状态</TableHead>
<TableHead className="w-[88px] text-right">操作</TableHead>
<TableHead>{t("channel.columnAgent")}</TableHead>
<TableHead>{t("channel.columnStatus")}</TableHead>
<TableHead className="w-[88px] text-right">{t("channel.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -300,8 +312,8 @@ export default function DashboardChannelsPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载接入渠道..."
emptyText="暂无接入渠道"
loadingText={t("channel.loading")}
emptyText={t("channel.empty")}
/>
) : null}
{result.results.map((item) => (
@@ -313,12 +325,12 @@ export default function DashboardChannelsPage() {
</div>
<div>
<div className="font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">{getChannelTypeLabel(item.channelType)}</div>
<div className="text-xs text-muted-foreground">{getChannelTypeLabel(item.channelType, t)}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{getChannelTypeLabel(item.channelType)}</Badge>
<Badge variant="outline">{getChannelTypeLabel(item.channelType, t)}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{item.channelId || "-"}</TableCell>
<TableCell>{item.aiAgentName || "-"}</TableCell>
@@ -328,22 +340,22 @@ export default function DashboardChannelsPage() {
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("channel.toggleStatus", { name: item.name })}
/>
<Badge variant={item.status === Status.Ok ? "default" : "outline"}>
{getEnumLabel(StatusLabels, item.status as Status)}
{getStatusLabel(item.status as Status, t)}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
编辑
{t("channel.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" className="ml-auto" />}
aria-label={`更多操作 ${item.name}`}
aria-label={t("channel.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -354,7 +366,7 @@ export default function DashboardChannelsPage() {
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="size-4" />
删除
{t("channel.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import type { Resolver } from "react-hook-form"
import { useForm } from "react-hook-form"
@@ -21,6 +21,7 @@ import {
type AdminCompany,
type CreateAdminCompanyPayload,
} from "@/lib/api/company"
import { useI18n } from "@/i18n/provider"
type CompanyEditDialogProps = {
open: boolean
@@ -31,19 +32,19 @@ type CompanyEditDialogProps = {
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 = {
name: string
code: string
remark: string
}
type EditForm = z.infer<typeof companyFormSchema>
const editFormResolver = zodResolver(companyFormSchema as never) as Resolver<
z.input<typeof companyFormSchema>,
undefined,
z.output<typeof companyFormSchema>
>
function createSchema(t: (key: string) => string) {
return z.object({
name: z.string().trim().min(1, t("company.nameRequired")),
code: z.string().trim(),
remark: z.string().trim(),
})
}
const emptyForm: EditForm = {
name: "",
@@ -112,8 +113,19 @@ function CompanyEditDialogBody({
onOpenChange,
onSubmit,
}: CompanyEditDialogBodyProps) {
const t = useI18n()
const formId = "company-edit-form"
const [loading, setLoading] = useState(false)
const companyFormSchema = useMemo(() => createSchema(t), [t])
const editFormResolver = useMemo(
() =>
zodResolver(companyFormSchema as never) as Resolver<
z.input<typeof companyFormSchema>,
undefined,
z.output<typeof companyFormSchema>
>,
[companyFormSchema]
)
const form = useForm<
z.input<typeof companyFormSchema>,
undefined,
@@ -154,7 +166,7 @@ function CompanyEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑公司" : "新建公司"}
title={itemId ? t("company.editTitle") : t("company.createTitle")}
size="md"
allowFullscreen
footer={
@@ -165,26 +177,26 @@ function CompanyEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("company.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("company.saving") : itemId ? t("company.save") : t("company.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("company.loadingDetail")}</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="company-name">公司名称</FieldLabel>
<FieldLabel htmlFor="company-name">{t("company.columnName")}</FieldLabel>
<FieldContent>
<Input
id="company-name"
placeholder="请输入公司名称"
placeholder={t("company.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -192,11 +204,11 @@ function CompanyEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="company-code">公司编码</FieldLabel>
<FieldLabel htmlFor="company-code">{t("company.columnCode")}</FieldLabel>
<FieldContent>
<Input
id="company-code"
placeholder="可选"
placeholder={t("company.optional")}
aria-invalid={!!errors.code}
{...register("code")}
/>
@@ -204,11 +216,11 @@ function CompanyEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="company-remark">备注</FieldLabel>
<FieldLabel htmlFor="company-remark">{t("company.columnRemark")}</FieldLabel>
<FieldContent>
<Textarea
id="company-remark"
placeholder="可选"
placeholder={t("company.remarkPlaceholder")}
rows={4}
aria-invalid={!!errors.remark}
{...register("remark")}
+51 -39
View File
@@ -50,19 +50,21 @@ import {
} from "@/lib/api/company"
import { getEnumOptions } from "@/lib/enums"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
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(status: Status, t: (key: string) => string) {
if (status === Status.Disabled) {
return t("status.disabled")
}
if (status === Status.Deleted) {
return t("status.deleted")
}
return t("status.ok")
}
export default function DashboardCompaniesPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("")
const [codeInput, setCodeInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -93,11 +95,21 @@ export default function DashboardCompaniesPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载公司列表失败")
toast.error(error instanceof Error ? error.message : t("company.loadFailed"))
} finally {
setLoading(false)
}
}, [code, limit, name, page, statusFilter])
}, [code, limit, name, page, statusFilter, t])
const listStatusOptions = [
{ value: "all", label: t("status.all") },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: getStatusLabel(item.value as Status, t),
})),
]
useEffect(() => {
void loadData()
@@ -143,16 +155,16 @@ export default function DashboardCompaniesPage() {
try {
if (editingItem) {
await updateCompany({ id: editingItem.id, ...payload })
toast.success(`已更新公司:${editingItem.name}`)
toast.success(t("company.updated", { name: editingItem.name }))
} else {
await createCompany(payload)
toast.success(`已创建公司:${payload.name}`)
toast.success(t("company.created", { name: payload.name }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存公司失败")
toast.error(error instanceof Error ? error.message : t("company.saveFailed"))
} finally {
setSaving(false)
}
@@ -163,10 +175,10 @@ export default function DashboardCompaniesPage() {
try {
const nextStatus = item.status === 0 ? 1 : 0
await updateCompanyStatus(item.id, nextStatus)
toast.success(`已${nextStatus === 0 ? "启用" : "禁用"}:${item.name}`)
toast.success(t(nextStatus === 0 ? "company.enabled" : "company.disabled", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("company.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -176,10 +188,10 @@ export default function DashboardCompaniesPage() {
setActionLoadingId(item.id)
try {
await deleteCompany(item.id)
toast.success(`已删除公司:${item.name}`)
toast.success(t("company.deleted", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除公司失败")
toast.error(error instanceof Error ? error.message : t("company.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -193,11 +205,11 @@ export default function DashboardCompaniesPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
刷新
{t("company.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
新建公司
{t("company.new")}
</Button>
</>
}
@@ -208,7 +220,7 @@ export default function DashboardCompaniesPage() {
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按公司名称筛选"
placeholder={t("company.filterName")}
className="pl-9"
/>
</div>
@@ -216,20 +228,20 @@ export default function DashboardCompaniesPage() {
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按公司编码筛选"
placeholder={t("company.filterCode")}
className="w-full sm:w-44"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
onChange={setStatusFilterInput}
placeholder="全部状态"
placeholder={t("status.all")}
options={[...listStatusOptions]}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("company.query")}
</Button>
</DashboardToolbar>
@@ -252,12 +264,12 @@ export default function DashboardCompaniesPage() {
<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>
<TableHead>{t("company.columnName")}</TableHead>
<TableHead>{t("company.columnCode")}</TableHead>
<TableHead className="w-28">{t("company.columnCustomerCount")}</TableHead>
<TableHead className="w-24">{t("company.columnStatus")}</TableHead>
<TableHead>{t("company.columnRemark")}</TableHead>
<TableHead className="w-40">{t("company.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -265,8 +277,8 @@ export default function DashboardCompaniesPage() {
<DashboardTableStateRow
colSpan={7}
loading={loading}
loadingText="正在加载公司数据..."
emptyText="暂无公司数据"
loadingText={t("company.loading")}
emptyText={t("company.empty")}
/>
) : (
result.results.map((item) => {
@@ -287,7 +299,7 @@ export default function DashboardCompaniesPage() {
: "secondary"
}
>
{StatusLabels[item.status as Status] ?? "未知"}
{StatusLabels[item.status as Status] ? getStatusLabel(item.status as Status, t) : t("company.unknownStatus")}
</Badge>
</TableCell>
<TableCell className="max-w-[320px]">
@@ -296,14 +308,14 @@ export default function DashboardCompaniesPage() {
<TableCell>
<ButtonGroup className="w-full justify-end">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
编辑
{t("company.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" disabled={actionLoading} />
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("company.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -313,16 +325,16 @@ export default function DashboardCompaniesPage() {
onClick={() => void handleToggleStatus(item)}
>
{actionLoading ? (
"处理中..."
t("company.processing")
) : item.status === Status.Ok ? (
<>
<BanIcon />
禁用
{t("company.disable")}
</>
) : (
<>
<CheckCircle2Icon />
启用
{t("company.enable")}
</>
)}
</DropdownMenuItem>
@@ -332,7 +344,7 @@ export default function DashboardCompaniesPage() {
onClick={() => void handleDelete(item)}
>
<Trash2Icon />
删除
{t("company.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -26,6 +26,7 @@ import {
renderIMMessageHTML,
} from "@/lib/im-message";
import { formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
type ConversationDetailDialogProps = {
open: boolean;
@@ -34,7 +35,7 @@ type ConversationDetailDialogProps = {
item: AdminConversation | null;
detail: AdminConversationDetail | null;
messages?: AdminMessage[] | null;
/** 是否还有更早消息(cursor 分页) */
/** Whether older messages are available through cursor pagination. */
messagesHasMore?: boolean;
loadingMoreMessages?: boolean;
onLoadMoreMessages?: () => void | Promise<void>;
@@ -46,44 +47,53 @@ type ConversationDetailDialogProps = {
onOpenClose: () => void;
};
function getStatusMeta(status: number) {
function getStatusMeta(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (status) {
case 1:
return { label: "AI接待中", variant: "secondary" as const };
return { label: t("conversation.filterAiServing"), variant: "secondary" as const };
case 2:
return { label: "待接入", variant: "outline" as const };
return { label: t("conversation.filterPending"), variant: "outline" as const };
case 3:
return { label: "处理中", variant: "secondary" as const };
return { label: t("conversation.filterActive"), variant: "secondary" as const };
case 4:
return { label: "已关闭", variant: "outline" as const };
return { label: t("conversation.filterClosed"), variant: "outline" as const };
default:
return { label: "未知", variant: "outline" as const };
return { label: t("conversationMonitor.unknown"), variant: "outline" as const };
}
}
function getServiceModeLabel(mode: number) {
function getServiceModeLabel(
mode: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (mode) {
case 1:
return "AI 接待";
return t("conversationMonitor.serviceAi");
case 2:
return "人工接待";
return t("conversationMonitor.serviceHuman");
case 3:
return "AI 优先";
return t("conversationMonitor.serviceAiFirst");
default:
return "未定义";
return t("conversationMonitor.serviceUndefined");
}
}
function getSenderLabel(message: AdminMessage) {
function getSenderLabel(
message: AdminMessage,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (message.senderType) {
case "agent":
return message.senderName || "客服";
return message.senderName || t("conversationMonitor.senderAgent");
case "customer":
return message.senderName || "用户";
return message.senderName || t("conversationMonitor.senderCustomer");
case "ai":
return "AI";
case "system":
return "系统";
return t("conversationMonitor.senderSystem");
default:
return message.senderType;
}
@@ -150,12 +160,13 @@ export function ConversationDetailDialog({
onRead,
onOpenClose,
}: ConversationDetailDialogProps) {
const t = useI18n();
const messages = Array.isArray(rawMessages) ? rawMessages : [];
const currentConversation = detail ?? item;
const isClosedConversation = currentConversation?.status === 4;
const isPendingConversation = currentConversation?.status === 2;
const statusMeta = currentConversation
? getStatusMeta(currentConversation.status)
? getStatusMeta(currentConversation.status, t)
: null;
const messageBottomRef = useRef<HTMLDivElement | null>(null);
const messagesScrollRootRef = useRef<HTMLDivElement | null>(null);
@@ -249,7 +260,7 @@ export function ConversationDetailDialog({
onOpenChange={onOpenChange}
title={
<div className="flex items-center gap-3">
<span>{currentConversation?.customerName || "会话详情"}</span>
<span>{currentConversation?.customerName || t("conversationMonitor.detailFallbackTitle")}</span>
<div>
{statusMeta ? (
@@ -268,8 +279,10 @@ export function ConversationDetailDialog({
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<div className="text-sm text-muted-foreground">
{currentConversation
? `最后活跃:${formatDateTime(currentConversation.lastMessageAt)}`
: "暂无会话信息"}
? t("conversationMonitor.lastActivePrefix", {
time: formatDateTime(currentConversation.lastMessageAt),
})
: t("conversationMonitor.noConversationInfo")}
</div>
<div className="flex flex-wrap gap-2">
<Button
@@ -278,7 +291,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation || currentConversation.status !== 2}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "分配会话"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.assign")}
</Button>
<Button
variant="outline"
@@ -288,7 +301,7 @@ export function ConversationDetailDialog({
}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "重试分配"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.retryDispatch")}
</Button>
<Button
variant="outline"
@@ -296,7 +309,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation}
>
<CheckCheckIcon />
{saving ? "处理中..." : "标记已读"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.markRead")}
</Button>
<Button
type="button"
@@ -305,7 +318,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation || currentConversation.status !== 3}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "转接会话"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.transfer")}
</Button>
{!isClosedConversation ? (
<Button
@@ -314,7 +327,7 @@ export function ConversationDetailDialog({
disabled={saving || !currentConversation}
>
<EyeIcon />
{saving ? "处理中..." : "关闭会话"}
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.close")}
</Button>
) : null}
</div>
@@ -323,7 +336,7 @@ export function ConversationDetailDialog({
>
{loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
正在加载会话详情...
{t("conversationMonitor.loadingDetail")}
</div>
) : currentConversation ? (
<div className="flex min-h-0 flex-1 flex-row overflow-hidden border-t">
@@ -331,32 +344,32 @@ export function ConversationDetailDialog({
<div className="space-y-4 p-6">
<div className="grid grid-cols-2 gap-3 text-sm">
<InfoItem
label="接待模式"
value={getServiceModeLabel(currentConversation.serviceMode)}
label={t("conversationMonitor.columnServiceMode")}
value={getServiceModeLabel(currentConversation.serviceMode, t)}
/>
<InfoItem
label="当前客服"
label={t("conversationMonitor.currentAssignee")}
value={currentConversation.currentAssigneeName || "-"}
/>
<InfoItem
label="渠道ID"
label={t("conversation.channelId")}
value={`${currentConversation.channelId || "-"}`}
/>
<InfoItem
label="客服未读"
label={t("conversationMonitor.agentUnread")}
value={`${currentConversation.agentUnreadCount}`}
/>
<InfoItem
label="用户未读"
label={t("conversationMonitor.customerUnread")}
value={`${currentConversation.customerUnreadCount}`}
/>
<InfoItem
label="最后活跃"
label={t("conversationMonitor.columnLastActive")}
value={formatDateTime(currentConversation.lastMessageAt)}
fullWidth
/>
<InfoItem
label="关闭时间"
label={t("conversationMonitor.closedAt")}
value={formatDateTime(currentConversation.closedAt)}
fullWidth
/>
@@ -369,9 +382,13 @@ export function ConversationDetailDialog({
<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>
<p className="text-sm font-medium">
{t("conversationMonitor.participants")}
</p>
<span className="text-xs text-muted-foreground">
{detail?.participants?.length ?? 0} 人
{t("conversationMonitor.participantCount", {
count: detail?.participants?.length ?? 0,
})}
</span>
</div>
{detail?.participants?.length ? (
@@ -387,17 +404,21 @@ export function ConversationDetailDialog({
</span>
</div>
<div className="mt-1 text-sm text-muted-foreground">
标识:{getParticipantIdentity(participant)}
{t("conversationMonitor.participantIdentity", {
value: getParticipantIdentity(participant),
})}
</div>
<div className="mt-1 text-xs text-muted-foreground">
加入时间:{formatDateTime(participant.joinedAt)}
{t("conversationMonitor.participantJoinedAt", {
time: formatDateTime(participant.joinedAt),
})}
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background p-4 text-sm text-muted-foreground">
暂无参与方信息
{t("conversationMonitor.emptyParticipants")}
</div>
)}
</section>
@@ -406,16 +427,6 @@ export function ConversationDetailDialog({
</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">
@@ -425,8 +436,8 @@ export function ConversationDetailDialog({
className="flex min-h-8 flex-col items-center justify-center py-2 text-xs text-muted-foreground"
>
{loadingMoreMessages
? "正在加载更早的消息…"
: "继续上滑加载更早消息"}
? t("conversationMonitor.loadingOlder")
: t("conversationMonitor.loadOlderHint")}
</div>
) : null}
{messages.length ? (
@@ -449,13 +460,13 @@ export function ConversationDetailDialog({
<div
className={`text-xs text-muted-foreground ${layout.metaClassName}`}
>
<span>{getSenderLabel(message)}</span>
<span>{getSenderLabel(message, t)}</span>
<span className="mx-2">·</span>
<span>{formatDateTime(message.sentAt)}</span>
{isRecalled ? (
<>
<span className="mx-2">·</span>
<span>已撤回</span>
<span>{t("conversationMonitor.messageRecalled")}</span>
</>
) : null}
</div>
@@ -463,7 +474,9 @@ export function ConversationDetailDialog({
className={`rounded-2xl border px-4 py-3 text-sm leading-6 ${layout.bubbleClassName}`}
>
{isRecalled ? (
<div className="text-muted-foreground">该消息已撤回</div>
<div className="text-muted-foreground">
{t("conversationMonitor.messageRecalledBody")}
</div>
) : isHtmlMessage ? (
<ImMessageHTML
html={renderIMMessageHTML(message)}
@@ -485,8 +498,14 @@ export function ConversationDetailDialog({
<div
className={`text-xs text-muted-foreground ${layout.metaClassName}`}
>
客服 {message.agentRead ? "已读" : "未读"} / 用户{" "}
{message.customerRead ? "已读" : "未读"}
{t("conversationMonitor.readStatus", {
agent: message.agentRead
? t("conversationMonitor.read")
: t("conversationMonitor.unread"),
customer: message.customerRead
? t("conversationMonitor.read")
: t("conversationMonitor.unread"),
})}
</div>
</div>
</div>
@@ -494,7 +513,7 @@ export function ConversationDetailDialog({
})
) : (
<div className="flex h-full min-h-80 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无消息记录
{t("conversationMonitor.emptyMessages")}
</div>
)}
<div ref={messageBottomRef} />
@@ -505,7 +524,7 @@ export function ConversationDetailDialog({
</div>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
暂无可展示的会话详情
{t("conversationMonitor.emptyDetail")}
</div>
)}
</ProjectDialog>
@@ -534,10 +553,11 @@ type MessageImageProps = {
};
function MessageImage({ src, alt, onPreview }: MessageImageProps) {
const t = useI18n();
if (!src) {
return (
<div className="text-sm whitespace-pre-wrap break-words">
{alt || "[图片]"}
{alt || t("conversationMonitor.imageFallback")}
</div>
);
}
@@ -550,7 +570,7 @@ function MessageImage({ src, alt, onPreview }: MessageImageProps) {
>
<Image
src={src}
alt={alt || "消息图片"}
alt={alt || t("conversationMonitor.messageImageAlt")}
width={480}
height={360}
className="max-h-64 w-auto max-w-full rounded-md object-contain"
+116 -93
View File
@@ -7,7 +7,7 @@ import {
RefreshCwIcon,
SearchIcon,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
import { toast } from "sonner"
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"
@@ -33,13 +33,6 @@ import {
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,
@@ -66,50 +59,57 @@ import {
type PageResult,
type TagTree,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
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) {
function getStatusMeta(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (status) {
case 1:
return { label: "AI接待中", variant: "secondary" as const }
return { label: t("conversation.filterAiServing"), variant: "secondary" as const }
case 2:
return { label: "待接入", variant: "outline" as const }
return { label: t("conversation.filterPending"), variant: "outline" as const }
case 3:
return { label: "处理中", variant: "secondary" as const }
return { label: t("conversation.filterActive"), variant: "secondary" as const }
case 4:
return { label: "已关闭", variant: "outline" as const }
return { label: t("conversation.filterClosed"), variant: "outline" as const }
default:
return { label: "未知", variant: "outline" as const }
return { label: t("conversationMonitor.unknown"), variant: "outline" as const }
}
}
function getServiceModeLabel(mode: number) {
function getServiceModeLabel(
mode: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (mode) {
case 1:
return "AI 接待"
return t("conversationMonitor.serviceAi")
case 2:
return "人工接待"
return t("conversationMonitor.serviceHuman")
case 3:
return "AI 优先"
return t("conversationMonitor.serviceAiFirst")
default:
return "未定义"
return t("conversationMonitor.serviceUndefined")
}
}
function getStatusLabel(value: string) {
return statusOptions.find((item) => item.value === value)?.label ?? "全部状态"
function getStatusOptions(
t: (key: string, values?: Record<string, string | number>) => string
) {
return [
{ value: "all", label: t("conversationMonitor.allStatuses") },
{ value: "1", label: t("conversation.filterAiServing") },
{ value: "2", label: t("conversation.filterPending") },
{ value: "3", label: t("conversation.filterActive") },
{ value: "4", label: t("conversation.filterClosed") },
]
}
function buildTagOptions(
@@ -131,6 +131,8 @@ function buildTagOptions(
}
export default function DashboardConversationsPage() {
const t = useI18n()
const statusOptions = useMemo(() => getStatusOptions(t), [t])
const [keywordInput, setKeywordInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
const [tagFilterInput, setTagFilterInput] = useState("0")
@@ -144,13 +146,13 @@ export default function DashboardConversationsPage() {
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [tagOptions, setTagOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部标签" },
{ value: "0", label: t("conversationMonitor.allTags") },
])
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部指派人" },
{ value: "0", label: t("conversationMonitor.allAssignees") },
])
const [agentTeamOptions, setAgentTeamOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部客服组" },
{ value: "0", label: t("conversationMonitor.allTeams") },
])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -194,11 +196,11 @@ export default function DashboardConversationsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载会话列表失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadListFailed"))
} finally {
setLoading(false)
}
}, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter])
}, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter, t])
useEffect(() => {
let cancelled = false
@@ -212,18 +214,18 @@ export default function DashboardConversationsPage() {
])
if (!cancelled) {
setTagOptions([
{ value: "0", label: "全部标签" },
{ value: "0", label: t("conversationMonitor.allTags") },
...buildTagOptions(tagData),
])
setAssigneeOptions([
{ value: "0", label: "全部指派人" },
{ value: "0", label: t("conversationMonitor.allAssignees") },
...assigneeData.map((item: AdminAgentProfile) => ({
value: String(item.userId),
label: item.displayName || item.nickname || item.username || `#${item.userId}`,
})),
])
setAgentTeamOptions([
{ value: "0", label: "全部客服组" },
{ value: "0", label: t("conversationMonitor.allTeams") },
...teamData.map((item: AdminAgentTeam) => ({
value: String(item.id),
label: item.name,
@@ -232,7 +234,7 @@ export default function DashboardConversationsPage() {
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载筛选项失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadFiltersFailed"))
}
}
}
@@ -242,7 +244,7 @@ export default function DashboardConversationsPage() {
return () => {
cancelled = true
}
}, [])
}, [t])
useEffect(() => {
detailItemRef.current = detailItem
@@ -290,7 +292,7 @@ export default function DashboardConversationsPage() {
try {
socket = new WebSocket(createAdminWebSocketUrl())
} catch (error) {
toast.error(error instanceof Error ? error.message : "连接实时服务失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.realtimeConnectFailed"))
scheduleReconnect()
return
}
@@ -382,7 +384,7 @@ export default function DashboardConversationsPage() {
}
subscribedConversationIdRef.current = null
}
}, [loadConversations])
}, [loadConversations, t])
useEffect(() => {
const socket = websocketRef.current
@@ -466,7 +468,7 @@ export default function DashboardConversationsPage() {
setDetailMessagesNextCursor(messages.cursor ?? "")
setDetailMessagesHasMore(Boolean(messages.hasMore))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载会话详情失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadDetailFailed"))
} finally {
setDetailLoading(false)
}
@@ -492,7 +494,7 @@ export default function DashboardConversationsPage() {
setDetailMessagesNextCursor(page.cursor ?? "")
setDetailMessagesHasMore(Boolean(page.hasMore))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载更多消息失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadMoreMessagesFailed"))
} finally {
setDetailMessagesLoadingMore(false)
}
@@ -501,6 +503,7 @@ export default function DashboardConversationsPage() {
detailMessagesHasMore,
detailMessagesLoadingMore,
detailMessagesNextCursor,
t,
])
async function openDetail(item: AdminConversation) {
@@ -603,13 +606,13 @@ export default function DashboardConversationsPage() {
setActionLoadingId(item.id)
try {
await markConversationRead(item.id)
toast.success(`已标记已读:${item.customerName || `#${item.id}`}`)
toast.success(t("conversationMonitor.markedRead", { name: item.customerName || `#${item.id}` }))
await loadConversations()
if (detailItem?.id === item.id) {
await refreshDetail()
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "标记已读失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.markReadFailed"))
} finally {
setActionLoadingId(null)
}
@@ -619,13 +622,13 @@ export default function DashboardConversationsPage() {
setActionLoadingId(item.id)
try {
await dispatchConversation(item.id)
toast.success(`已触发自动分配:${item.customerName || `#${item.id}`}`)
toast.success(t("conversationMonitor.dispatchTriggered", { name: item.customerName || `#${item.id}` }))
await loadConversations()
if (detailItem?.id === item.id) {
await refreshDetail()
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "自动分配失败")
toast.error(error instanceof Error ? error.message : t("conversationMonitor.dispatchFailed"))
} finally {
setActionLoadingId(null)
}
@@ -649,7 +652,7 @@ export default function DashboardConversationsPage() {
disabled={loading || refreshing}
>
<RefreshCwIcon className={loading || refreshing ? "animate-spin" : ""} />
刷新
{t("conversationMonitor.refresh")}
</Button>
}
>
@@ -659,29 +662,25 @@ export default function DashboardConversationsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按主题或摘要筛选"
placeholder={t("conversationMonitor.keywordPlaceholder")}
className="pl-9"
/>
</div>
<Select value={statusFilterInput} onValueChange={handleStatusFilterChange}>
<SelectTrigger className="w-full sm: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 sm:w-36">
<OptionCombobox
value={statusFilterInput}
options={statusOptions}
placeholder={t("conversationMonitor.allStatuses")}
onChange={handleStatusFilterChange}
/>
</div>
<div className="w-full sm:w-64">
<OptionCombobox
value={tagFilterInput}
options={tagOptions}
placeholder="选择标签"
searchPlaceholder="搜索标签路径"
emptyText="没有匹配标签"
placeholder={t("conversationMonitor.selectTag")}
searchPlaceholder={t("conversationMonitor.searchTagPath")}
emptyText={t("conversationMonitor.emptyTags")}
onChange={setTagFilterInput}
/>
</div>
@@ -689,9 +688,9 @@ export default function DashboardConversationsPage() {
<OptionCombobox
value={assigneeFilterInput}
options={assigneeOptions}
placeholder="选择指派人"
searchPlaceholder="搜索指派人"
emptyText="没有匹配指派人"
placeholder={t("conversationMonitor.selectAssignee")}
searchPlaceholder={t("conversationMonitor.searchAssignee")}
emptyText={t("conversationMonitor.emptyAssignees")}
onChange={setAssigneeFilterInput}
/>
</div>
@@ -699,15 +698,15 @@ export default function DashboardConversationsPage() {
<OptionCombobox
value={agentTeamFilterInput}
options={agentTeamOptions}
placeholder="选择客服组"
searchPlaceholder="搜索客服组"
emptyText="没有匹配客服组"
placeholder={t("conversationMonitor.selectTeam")}
searchPlaceholder={t("conversationMonitor.searchTeam")}
emptyText={t("conversationMonitor.emptyTeams")}
onChange={setAgentTeamFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("conversationMonitor.query")}
</Button>
</DashboardToolbar>
@@ -726,39 +725,51 @@ export default function DashboardConversationsPage() {
<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>
<TableHead>{t("conversationMonitor.columnConversation")}</TableHead>
<TableHead>{t("conversationMonitor.columnStatus")}</TableHead>
<TableHead>{t("conversationMonitor.columnServiceMode")}</TableHead>
<TableHead>{t("conversationMonitor.columnAssignee")}</TableHead>
<TableHead>{t("conversationMonitor.columnUnread")}</TableHead>
<TableHead>{t("conversationMonitor.columnLastActive")}</TableHead>
<TableHead className="w-28 text-right">
{t("conversationMonitor.columnActions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => {
const statusMeta = getStatusMeta(item.status)
const statusMeta = getStatusMeta(item.status, t)
return (
<TableRow key={item.id}>
<TableCell className="max-w-60">
<div className="min-w-0">
<div className="font-medium">{item.customerName || `客户 #${item.customerId || item.id}`}</div>
<div className="font-medium">
{item.customerName ||
t("conversationMonitor.customerFallback", {
id: item.customerId || item.id,
})}
</div>
<div className="mt-1 text-sm text-muted-foreground">
渠道ID:{item.channelId || "-"}
{t("conversationMonitor.channelIdValue", {
id: item.channelId || "-",
})}
</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.lastMessageSummary || "暂无最新消息摘要"}
{item.lastMessageSummary || t("conversationMonitor.noLatestSummary")}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
</TableCell>
<TableCell>{getServiceModeLabel(item.serviceMode)}</TableCell>
<TableCell>{getServiceModeLabel(item.serviceMode, t)}</TableCell>
<TableCell>{item.currentAssigneeName || "-"}</TableCell>
<TableCell>
客服 {item.agentUnreadCount} / 用户 {item.customerUnreadCount}
{t("conversationMonitor.unreadSummary", {
agent: item.agentUnreadCount,
customer: item.customerUnreadCount,
})}
</TableCell>
<TableCell>{formatDateTime(item.lastMessageAt)}</TableCell>
<TableCell className="text-right">
@@ -769,12 +780,14 @@ export default function DashboardConversationsPage() {
onClick={() => void openDetail(item)}
disabled={actionLoadingId === item.id}
>
查看
{t("conversationMonitor.view")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.customerName || item.id}`}
aria-label={t("conversationMonitor.moreActions", {
name: item.customerName || item.id,
})}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -784,28 +797,36 @@ export default function DashboardConversationsPage() {
disabled={actionLoadingId === item.id || item.status !== 2}
>
<MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "分配会话"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.assign")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDispatch(item)}
disabled={actionLoadingId === item.id || item.status !== 2}
>
<RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "重试分配"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.retryDispatch")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleRead(item)}
disabled={actionLoadingId === item.id}
>
<CheckCheckIcon />
{actionLoadingId === item.id ? "处理中..." : "标记已读"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.markRead")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openTransfer(item)}
disabled={actionLoadingId === item.id || item.status !== 3}
>
<MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "转接会话"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.transfer")}
</DropdownMenuItem>
{item.status !== 4 ? (
<DropdownMenuItem
@@ -813,7 +834,9 @@ export default function DashboardConversationsPage() {
disabled={actionLoadingId === item.id}
>
<RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "关闭会话"}
{actionLoadingId === item.id
? t("conversationMonitor.processing")
: t("conversationMonitor.close")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -827,8 +850,8 @@ export default function DashboardConversationsPage() {
<DashboardTableStateRow
colSpan={7}
loading={loading}
loadingText="正在加载会话记录..."
emptyText="没有匹配的会话记录"
loadingText={t("conversationMonitor.loadingRows")}
emptyText={t("conversationMonitor.emptyRows")}
/>
) : null}
</TableBody>
@@ -7,6 +7,7 @@ import {
SharedMessageEditor,
type UploadedMessageEditorImage,
} from "@/components/chat/shared-message-editor"
import { useI18n } from "@/i18n/provider"
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
type AgentMessageEditorProps = {
@@ -24,6 +25,7 @@ export function AgentMessageEditor({
onUploadImage,
onSendAttachment,
}: AgentMessageEditorProps) {
const t = useI18n()
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
const [loadingQuickReplies, setLoadingQuickReplies] = useState(true)
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
@@ -38,7 +40,7 @@ export function AgentMessageEditor({
})
.catch((error) => {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载快捷回复失败")
toast.error(error instanceof Error ? error.message : t("conversation.loadQuickRepliesFailed"))
}
})
.finally(() => {
@@ -49,7 +51,7 @@ export function AgentMessageEditor({
return () => {
cancelled = true
}
}, [])
}, [t])
return (
<SharedMessageEditor
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { ImMessageHTML } from "@/components/im-message-html";
import { useImageLightbox } from "@/components/image-lightbox";
import { useI18n } from "@/i18n/provider";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
@@ -39,6 +40,7 @@ import { AgentMessageEditor } from "./agent-message-editor";
const EMPTY_AGENT_MESSAGES: AgentMessage[] = [];
export function ChatPanel() {
const t = useI18n();
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
@@ -119,8 +121,8 @@ export function ChatPanel() {
}, [getViewport]);
/**
* 与 widget 消息列表一致:在单条调度链内多帧滚底直到 scrollHeight 稳定,
* 避免多段滚底叠加导致滚动条抖动。
* Match the widget message list: keep scrolling for a few frames until
* scrollHeight stabilizes, which prevents stacked scroll jumps.
*/
const scheduleScrollToBottom = useCallback(
(attempts = 4) => {
@@ -170,7 +172,7 @@ export function ChatPanel() {
return;
}
void markSelectedConversationRead().catch((error) => {
toast.error(error instanceof Error ? error.message : "设置已读失败");
toast.error(error instanceof Error ? error.message : t("conversation.markReadFailed"));
});
}, [
conversation,
@@ -178,6 +180,7 @@ export function ChatPanel() {
isNearBottom,
loading,
markSelectedConversationRead,
t,
]);
useEffect(() => {
@@ -282,7 +285,7 @@ export function ChatPanel() {
await loadOlderMessages();
} catch (error) {
prependScrollAnchorRef.current = null;
toast.error(error instanceof Error ? error.message : "加载历史消息失败");
toast.error(error instanceof Error ? error.message : t("conversation.loadHistoryFailed"));
}
};
@@ -292,7 +295,7 @@ export function ChatPanel() {
shouldStickToBottomRef.current = true;
await sendMessage(html);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送消息失败");
toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed"));
}
};
@@ -300,7 +303,7 @@ export function ChatPanel() {
if (!conversation || claiming) return;
const session = readSession();
if (!session?.user?.id) {
toast.error("未登录或登录已过期");
toast.error(t("conversation.claimRequiresSignIn"));
return;
}
@@ -309,15 +312,15 @@ export function ChatPanel() {
await assignAgentConversation(
conversation.id,
session.user.id,
"认领会话",
t("conversation.claimReason"),
);
switchToMyActiveIfNeeded();
setClaimDialogOpen(false);
toast.success("认领成功");
toast.success(t("conversation.claimSuccess"));
await reloadConversationData(conversation.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "认领会话失败");
toast.error(error instanceof Error ? error.message : t("conversation.claimFailed"));
} finally {
setClaiming(false);
}
@@ -332,9 +335,13 @@ export function ChatPanel() {
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>
<p className="text-lg">{t("conversation.empty")}</p>
<p className="mt-1 text-sm lg:hidden">
{t("conversation.noConversationMobile")}
</p>
<p className="mt-1 hidden text-sm lg:block">
{t("conversation.selectConversationToChat")}
</p>
</div>
</div>
);
@@ -355,13 +362,13 @@ export function ChatPanel() {
disabled={messagesLoadingMore}
onClick={() => void handleLoadOlder()}
>
{messagesLoadingMore ? "加载中…" : "加载更早的消息"}
{messagesLoadingMore ? t("conversation.loading") : t("conversation.loadOlder")}
</Button>
</div>
) : null}
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
加载中...
{t("conversation.loading")}
</div>
) : messages.length > 0 ? (
messages.map((message) => (
@@ -378,7 +385,7 @@ export function ChatPanel() {
))
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
暂无消息
{t("conversation.emptyMessages")}
</div>
)}
</div>
@@ -389,11 +396,11 @@ export function ChatPanel() {
<div className="h-full overflow-auto border-t border-border/80 bg-card">
{isClosedConversation ? (
<div className="h-full flex justify-center items-center">
当前会话已关闭
{t("conversation.closedNotice")}
</div>
) : conversation?.status === 1 ? (
<div className="h-full flex justify-center items-center">
当前会话由 AI 接待中,转人工后才能由客服发送消息
{t("conversation.aiServingNotice")}
</div>
) : isPendingConversation ? (
<div className="h-full flex justify-center items-center">
@@ -403,7 +410,7 @@ export function ChatPanel() {
disabled={claiming}
size="sm"
>
{claiming ? "认领中..." : "认领"}
{claiming ? t("conversation.claiming") : t("conversation.claim")}
</Button>
</div>
</div>
@@ -424,7 +431,7 @@ export function ChatPanel() {
try {
await sendAttachment(file);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送附件失败");
toast.error(error instanceof Error ? error.message : t("conversation.sendAttachmentFailed"));
}
}}
/>
@@ -477,11 +484,14 @@ export function ChatPanel() {
>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>确认认领会话</DialogTitle>
<DialogTitle>{t("conversation.claimTitle")}</DialogTitle>
<DialogDescription>
{conversation
? `确认认领“${conversation.customerName || `客户 #${conversation.customerId || conversation.id}`}”吗?认领后会话会进入我的列表。`
: "确认认领当前会话吗?"}
? `${t("conversation.claimConfirmPrefix")}${
conversation.customerName ||
`${t("conversation.customerFallbackPrefix")}${conversation.customerId || conversation.id}`
}${t("conversation.claimConfirmSuffix")}`
: t("conversation.claimCurrent")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -491,14 +501,14 @@ export function ChatPanel() {
disabled={claiming}
onClick={() => setClaimDialogOpen(false)}
>
取消
{t("conversation.cancel")}
</Button>
<Button
type="button"
disabled={claiming}
onClick={() => void handleClaim()}
>
{claiming ? "认领中..." : "确认认领"}
{claiming ? t("conversation.claiming") : t("conversation.confirmClaim")}
</Button>
</DialogFooter>
</DialogContent>
@@ -532,22 +542,25 @@ const MessageItem = memo(
recalling,
onRecall,
}: MessageItemProps) {
const t = useI18n();
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 || "客户"
? message.senderName || t("conversation.customerSender")
: isAi
? "AI"
: message.senderName || "客服";
: message.senderName || t("conversation.agentSender");
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 htmlContent = isRecalled
? `<p>${t("conversation.messageRecalledHtml")}</p>`
: buildMessageHTML(message);
const bubbleClassName = isAi
? "border border-primary/15 bg-primary/5 text-foreground shadow-sm"
: isAgentSide
@@ -597,9 +610,13 @@ const MessageItem = memo(
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span>已撤回</span> : null}
{isRecalled ? <span>{t("conversation.messageRecalled")}</span> : null}
{message.sendStatus === 2 && !isRecalled && (
<span>{message.customerRead ? "客户已读" : "客户未读"}</span>
<span>
{message.customerRead
? t("conversation.customerRead")
: t("conversation.customerUnread")}
</span>
)}
{showRecallAction ? (
<Button
@@ -610,11 +627,11 @@ const MessageItem = memo(
disabled={recalling}
onClick={() => {
void onRecall(message.id).catch((error) => {
toast.error(error instanceof Error ? error.message : "撤回消息失败");
toast.error(error instanceof Error ? error.message : t("conversation.recallFailed"));
});
}}
>
{recalling ? "撤回中..." : "撤回"}
{recalling ? t("conversation.recalling") : t("conversation.recall")}
</Button>
) : null}
</div>
@@ -631,7 +648,7 @@ const MessageItem = memo(
<Avatar className="size-8 shrink-0">
<AvatarImage src="" />
<AvatarFallback className={avatarClassName}>
客
{t("conversation.customerAvatar")}
</AvatarFallback>
</Avatar>
<div className="max-w-[70%]">
@@ -652,7 +669,7 @@ const MessageItem = memo(
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span>已撤回</span> : null}
{isRecalled ? <span>{t("conversation.messageRecalled")}</span> : null}
</div>
</div>
</>
@@ -43,20 +43,31 @@ import {
} 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 { useI18n } from "@/i18n/provider";
import {
ConversationTagBadges,
ConversationTagPicker,
} from "./conversation-tag-picker";
import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge";
function contactTypeLabel(contactType: ContactType | string) {
return ContactTypeLabels[contactType as ContactType] ?? contactType;
function contactTypeLabel(
contactType: ContactType | string,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (contactType) {
case ContactType.Mobile:
return t("conversation.contactMobile");
case ContactType.Email:
return t("conversation.contactEmail");
case ContactType.Other:
return t("conversation.contactOther");
default:
return String(contactType);
}
}
function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) {
@@ -113,6 +124,7 @@ function SectionHeading({
}
function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
@@ -120,9 +132,11 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
<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="text-sm font-medium text-foreground">
{t("conversation.unlinkedCustomerTitle")}
</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
当前会话未绑定客户主档。绑定后可在此维护公司与联系方式。
{t("conversation.unlinkedCustomerDescription")}
</p>
<Button
type="button"
@@ -130,7 +144,7 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
onClick={() => setLinkDialogOpen(true)}
>
<Link2Icon className="size-4" />
关联或创建客户
{t("conversation.linkOrCreateCustomer")}
</Button>
</div>
<CustomerLinkOrCreateDialog
@@ -144,6 +158,7 @@ function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversati
}
function MissingCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
@@ -151,9 +166,11 @@ function MissingCustomerEmpty({ conversation }: { conversation: AgentConversatio
<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="text-sm font-medium text-foreground">
{t("conversation.missingCustomerTitle")}
</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
当前会话绑定的客户主档已不可用。你可以重新关联已有客户,或直接新建一个客户并绑定到当前会话。
{t("conversation.missingCustomerDescription")}
</p>
<Button
type="button"
@@ -161,14 +178,14 @@ function MissingCustomerEmpty({ conversation }: { conversation: AgentConversatio
onClick={() => setLinkDialogOpen(true)}
>
<Link2Icon className="size-4" />
重新关联或创建客户
{t("conversation.relinkOrCreateCustomer")}
</Button>
</div>
<div className="space-y-2">
<SectionHeading>会话归属</SectionHeading>
<SectionHeading>{t("conversation.conversationOwner")}</SectionHeading>
<div className="space-y-2">
<DetailRow label="渠道ID" value={conversation.channelId ? `${conversation.channelId}` : "-"} />
<DetailRow label="客户ID" value={conversation.customerId ? `${conversation.customerId}` : "-"} />
<DetailRow label={t("conversation.channelId")} value={conversation.channelId ? `${conversation.channelId}` : "-"} />
<DetailRow label={t("conversation.customerId")} value={conversation.customerId ? `${conversation.customerId}` : "-"} />
</div>
</div>
<CustomerLinkOrCreateDialog
@@ -192,6 +209,7 @@ export function ConversationInfoPanel({
className,
variant = "default",
}: ConversationInfoPanelProps) {
const t = useI18n();
const embedded = variant === "embedded";
return (
@@ -205,7 +223,9 @@ export function ConversationInfoPanel({
)}
>
<div className="flex h-12.5 shrink-0 items-center border-b border-border/80 bg-card px-3">
<h2 className="text-sm font-medium text-foreground">会话信息</h2>
<h2 className="text-sm font-medium text-foreground">
{t("conversation.conversationInfo")}
</h2>
</div>
<div
@@ -217,8 +237,8 @@ export function ConversationInfoPanel({
{!conversation ? (
<p className="pt-4 text-sm text-muted-foreground">
{embedded
? "请选择会话以查看会话信息"
: "请选择左侧会话以查看会话信息"}
? t("conversation.selectConversationForInfo")
: t("conversation.selectSidebarConversationForInfo")}
</p>
) : (
<div className="space-y-4 py-3">
@@ -235,6 +255,7 @@ function ConversationTagSection({
}: {
conversation: AgentConversation;
}) {
const t = useI18n();
const setConversationTags = useAgentConversationsStore(
(state) => state.setConversationTags,
);
@@ -253,7 +274,7 @@ function ConversationTagSection({
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载标签失败");
toast.error(error instanceof Error ? error.message : t("conversation.loadTagsFailed"));
}
} finally {
if (!cancelled) {
@@ -267,7 +288,7 @@ function ConversationTagSection({
return () => {
cancelled = true;
};
}, []);
}, [t]);
return (
<section className="space-y-2 border-t pt-2">
@@ -283,14 +304,14 @@ function ConversationTagSection({
/>
}
>
会话标签
{t("conversation.conversationTags")}
</SectionHeading>
<ConversationTagBadges
tags={conversation.tags}
availableTags={availableTags}
/>
{!conversation.tags || conversation.tags.length === 0 ? (
<p className="text-sm text-muted-foreground">暂未设置会话标签</p>
<p className="text-sm text-muted-foreground">{t("conversation.noConversationTags")}</p>
) : null}
</section>
);
@@ -317,6 +338,7 @@ type CustomerLinkedBodyProps = {
};
function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProps) {
const t = useI18n();
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<AdminCustomer | null>(null);
const [contacts, setContacts] = useState<AdminCustomerContact[]>([]);
@@ -337,14 +359,14 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
const list = await fetchCustomerContacts(customerId);
setContacts(Array.isArray(list) ? list : []);
} catch (e) {
const msg = e instanceof Error ? e.message : "加载客户信息失败";
const msg = e instanceof Error ? e.message : t("conversation.loadCustomerFailed");
toast.error(msg);
setCustomer(null);
setContacts([]);
} finally {
setLoading(false);
}
}, [customerId]);
}, [customerId, t]);
useEffect(() => {
void load();
@@ -360,7 +382,7 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
if (loading && !customer) {
return (
<p className="pt-4 text-sm text-muted-foreground">加载客户信息…</p>
<p className="pt-4 text-sm text-muted-foreground">{t("conversation.loadingCustomer")}</p>
);
}
@@ -373,18 +395,20 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
);
}
const displayName = customer.name.trim() || "未填写姓名";
const displayName = customer.name.trim() || t("conversation.unnamedCustomer");
const company = customer.company ?? null;
const genderLabel =
customer.gender === Gender.Male || customer.gender === Gender.Female
? GenderLabels[customer.gender as Gender] ?? String(customer.gender)
customer.gender === Gender.Male
? t("conversation.genderMale")
: customer.gender === Gender.Female
? t("conversation.genderFemale")
: 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">
客户主档已关联,但基础信息尚未填写。请点击「编辑」补全资料。
{t("conversation.customerProfileEmpty")}
</div>
) : null}
@@ -415,29 +439,29 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
onClick={() => setCustomerEditOpen(true)}
>
<PencilIcon className="size-3.5" />
编辑
{t("conversation.edit")}
</Button>
</div>
<div className="space-y-2">
<DetailRow
label="最近活跃"
label={t("conversation.lastActive")}
value={
customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""
}
/>
<DetailRow
label="备注"
label={t("conversation.remark")}
value={customer.remark.trim() ? customer.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
<DetailRow
label="创建时间"
label={t("conversation.createdAt")}
value={formatDateTime(customer.createdAt)}
valueClassName="whitespace-pre-wrap"
/>
<DetailRow
label="更新时间"
label={t("conversation.updatedAt")}
value={formatDateTime(customer.updatedAt)}
valueClassName="whitespace-pre-wrap"
/>
@@ -446,16 +470,16 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
<section className="space-y-2">
{contacts.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无联系方式</p>
<p className="text-sm text-muted-foreground">{t("conversation.noContacts")}</p>
) : (
<ul className="space-y-3">
{contacts.map((row) => {
const tags: string[] = [];
if (row.isPrimary) {
tags.push("主");
tags.push(t("conversation.primaryContactBadge"));
}
if (row.isVerified) {
tags.push("已验证");
tags.push(t("conversation.verifiedContactBadge"));
}
return (
<li key={row.id} className="text-sm">
@@ -465,7 +489,7 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
<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)}
{contactTypeLabel(row.contactType, t)}
</span>
{tags.length > 0 ? (
<span className="ml-2 text-xs text-muted-foreground">
@@ -516,28 +540,28 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
onClick={() => setCompanyEditOpen(true)}
>
<PencilIcon className="size-3.5" />
编辑
{t("conversation.edit")}
</Button>
</div>
<div className="space-y-2 pt-1">
<DetailRow
label="创建"
label={t("conversation.createdAt")}
value={formatDateTime(company.createdAt)}
/>
<DetailRow
label="更新"
label={t("conversation.updatedAt")}
value={formatDateTime(company.updatedAt)}
/>
</div>
<DetailRow
label="备注"
label={t("conversation.remark")}
value={company.remark.trim() ? company.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
公司信息加载失败或公司已删除。
{t("conversation.companyUnavailable")}
</p>
)}
</section>
@@ -559,11 +583,11 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
setCustomerEditSaving(true);
try {
await saveCustomerProfile({ ...payload, id: customer.id });
toast.success("已保存");
toast.success(t("conversation.saved"));
void load();
setCustomerEditOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
toast.error(e instanceof Error ? e.message : t("conversation.saveFailed"));
} finally {
setCustomerEditSaving(false);
}
@@ -584,6 +608,7 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp
}
function RelatedTicketsSection({ conversation }: { conversation: AgentConversation }) {
const t = useI18n();
const [tickets, setTickets] = useState<TicketItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -602,7 +627,7 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载关联工单失败");
toast.error(error instanceof Error ? error.message : t("conversation.loadTicketsFailed"));
}
} finally {
if (!cancelled) {
@@ -614,13 +639,13 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
return () => {
cancelled = true;
};
}, [conversation.id]);
}, [conversation.id, t]);
return (
<section className="space-y-2 border-t pt-2">
<SectionHeading>关联工单</SectionHeading>
<SectionHeading>{t("conversation.relatedTickets")}</SectionHeading>
{loading ? (
<p className="text-sm text-muted-foreground">加载工单中…</p>
<p className="text-sm text-muted-foreground">{t("conversation.loadingTickets")}</p>
) : tickets.length > 0 ? (
<div className="space-y-2">
{tickets.map((ticket) => (
@@ -648,7 +673,7 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
))}
</div>
) : (
<p className="text-sm text-muted-foreground">当前会话暂无关联工单</p>
<p className="text-sm text-muted-foreground">{t("conversation.noRelatedTickets")}</p>
)}
</section>
);
@@ -667,6 +692,7 @@ function CompanyEditDialog({
company,
onSaved,
}: CompanyEditDialogProps) {
const t = useI18n();
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [remark, setRemark] = useState("");
@@ -684,7 +710,7 @@ function CompanyEditDialog({
const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("公司名称不能为空");
toast.error(t("conversation.companyNameRequired"));
return;
}
setSaving(true);
@@ -695,11 +721,11 @@ function CompanyEditDialog({
code: code.trim(),
remark: remark.trim(),
});
toast.success("已保存");
toast.success(t("conversation.saved"));
onSaved();
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
toast.error(e instanceof Error ? e.message : t("conversation.saveFailed"));
} finally {
setSaving(false);
}
@@ -709,23 +735,23 @@ function CompanyEditDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle>编辑公司</DialogTitle>
<DialogTitle>{t("conversation.editCompany")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-1">
<Field orientation="vertical">
<FieldLabel htmlFor="co-name">公司名称</FieldLabel>
<FieldLabel htmlFor="co-name">{t("conversation.companyName")}</FieldLabel>
<FieldContent>
<Input id="co-name" value={name} onChange={(e) => setName(e.target.value)} />
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="co-code">公司编码</FieldLabel>
<FieldLabel htmlFor="co-code">{t("conversation.companyCode")}</FieldLabel>
<FieldContent>
<Input id="co-code" value={code} onChange={(e) => setCode(e.target.value)} />
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="co-remark">备注</FieldLabel>
<FieldLabel htmlFor="co-remark">{t("conversation.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="co-remark"
@@ -738,10 +764,10 @@ function CompanyEditDialog({
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
{t("conversation.cancel")}
</Button>
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? "保存中…" : "保存"}
{saving ? t("conversation.saving") : t("conversation.save")}
</Button>
</DialogFooter>
</DialogContent>
@@ -4,13 +4,10 @@ import { UserIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getEnumLabel } from "@/lib/enums";
import {
IMConversationStatus,
IMConversationStatusLabels,
} from "@/lib/generated/enums";
import { IMConversationStatus } from "@/lib/generated/enums";
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
import { formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
function getStatusVariant(status: number) {
switch (status) {
@@ -32,6 +29,7 @@ type ConversationListProps = {
}
export function ConversationList({ onAfterSelect }: ConversationListProps) {
const t = useI18n()
const conversations = useAgentConversationsStore((state) => state.conversations)
const loading = useAgentConversationsStore((state) => state.conversationsLoading)
const selectedId = useAgentConversationsStore((state) => state.selectedConversationId)
@@ -41,7 +39,7 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
<ScrollArea className="overflow-auto">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
加载中...
{t("conversation.loading")}
</div>
) : conversations.length > 0 ? (
conversations.map((conversation) => {
@@ -72,7 +70,10 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
<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.customerName || `客户 #${conversation.customerId || conversation.id}`}
{conversation.customerName ||
t("conversation.customerFallback", {
id: conversation.customerId || conversation.id,
})}
</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">
@@ -85,12 +86,12 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
<div className="mt-0.5 text-[11px] text-muted-foreground">
{conversation.lastMessageAt
? formatDateTime(conversation.lastMessageAt)
: "暂无时间"}
: t("conversation.noTime")}
</div>
</div>
</div>
<div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
{conversation.lastMessageSummary || "暂无最新消息"}
{conversation.lastMessageSummary || t("conversation.noLatestMessage")}
</div>
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<span
@@ -98,12 +99,14 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
conversation.status
)}`}
>
{getEnumLabel(IMConversationStatusLabels, conversation.status)}
{getConversationStatusLabel(conversation.status, t)}
</span>
{conversation.status === IMConversationStatus.Pending &&
conversation.currentTeamName ? (
<span className="rounded-md bg-muted px-1.5 py-0.5">
值班组:{conversation.currentTeamName}
{t("conversation.teamOnDuty", {
name: conversation.currentTeamName,
})}
</span>
) : null}
</div>
@@ -113,9 +116,27 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) {
})
) : (
<div className="p-6 text-center text-sm text-muted-foreground">
暂无会话
{t("conversation.empty")}
</div>
)}
</ScrollArea>
)
}
function getConversationStatusLabel(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
switch (status) {
case IMConversationStatus.AIServing:
return t("conversation.filterAiServing")
case IMConversationStatus.Pending:
return t("conversation.filterPending")
case IMConversationStatus.Active:
return t("conversation.filterActive")
case IMConversationStatus.Closed:
return t("conversation.filterClosed")
default:
return "-"
}
}
@@ -26,6 +26,7 @@ import {
type AgentConversationTag,
} from "@/lib/api/agent"
import { type TagTree } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
type TagNode = TagTree & {
@@ -73,6 +74,7 @@ export function ConversationTagPicker({
loading = false,
onTagsChange,
}: ConversationTagPickerProps) {
const t = useI18n()
const [pendingTagId, setPendingTagId] = useState<number | null>(null)
const flattenedTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
@@ -106,9 +108,9 @@ export function ConversationTagPicker({
})
}
onTagsChange(nextTags)
toast.success(exists ? "已移除会话标签" : "已添加会话标签")
toast.success(exists ? t("conversation.tagRemoved") : t("conversation.tagAdded"))
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新会话标签失败")
toast.error(error instanceof Error ? error.message : t("conversation.tagUpdateFailed"))
} finally {
setPendingTagId(null)
}
@@ -123,12 +125,12 @@ export function ConversationTagPicker({
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
aria-label="编辑会话标签"
aria-label={t("conversation.editTags")}
/>
}
>
<TagIcon className="size-3.5 text-muted-foreground" />
编辑
{t("conversation.edit")}
</PopoverTrigger>
<PopoverContent
align="end"
@@ -136,14 +138,14 @@ export function ConversationTagPicker({
onClick={(event) => event.stopPropagation()}
>
<Command>
<CommandInput placeholder="搜索标签" />
<CommandInput placeholder={t("conversation.searchTags")} />
<CommandList>
{loading ? <CommandEmpty>加载标签中...</CommandEmpty> : null}
{loading ? <CommandEmpty>{t("conversation.loadingTags")}</CommandEmpty> : null}
{!loading && flattenedTags.length === 0 ? (
<CommandEmpty>暂无可用标签</CommandEmpty>
<CommandEmpty>{t("conversation.emptyTags")}</CommandEmpty>
) : null}
{!loading ? (
<CommandGroup heading="标签">
<CommandGroup heading={t("conversation.tagGroup")}>
{flattenedTags.map((tag) => {
const checked = selectedTagIds.has(tag.id)
const pending = pendingTagId === tag.id
+39 -21
View File
@@ -36,6 +36,7 @@ import {
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime";
import { useI18n } from "@/i18n/provider";
import {
agentConversationFilterOptions,
agentConversationSelectors,
@@ -61,6 +62,7 @@ function getCustomerOnlineDotClassName(online?: boolean) {
}
export default function ConversationsPage() {
const t = useI18n();
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
@@ -117,11 +119,13 @@ export default function ConversationsPage() {
const currentFilterOption =
agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ??
agentConversationFilterOptions[0];
const getFilterLabel = (labelKey: string) => t(labelKey);
useEffect(() => {
void loadConversations().catch((error) => {
toast.error(error instanceof Error ? error.message : "加载会话列表失败");
toast.error(error instanceof Error ? error.message : t("conversation.loadListFailed"));
});
}, [loadConversations, conversationFilter]);
}, [loadConversations, conversationFilter, t]);
async function handleConversationChanged(conversationId: number) {
await loadConversations();
@@ -181,7 +185,11 @@ export default function ConversationsPage() {
/>
}
>
<span className="truncate">{currentFilterOption?.label ?? "筛选状态"}</span>
<span className="truncate">
{currentFilterOption
? getFilterLabel(currentFilterOption.labelKey)
: t("conversation.filterPlaceholder")}
</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44 min-w-44">
@@ -193,7 +201,7 @@ export default function ConversationsPage() {
>
{agentConversationFilterOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{opt.label}
{getFilterLabel(opt.labelKey)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@@ -216,7 +224,7 @@ export default function ConversationsPage() {
value={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{opt.label}
{getFilterLabel(opt.labelKey)}
</TabsTrigger>
))}
</TabsList>
@@ -233,7 +241,7 @@ export default function ConversationsPage() {
key={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{opt.label}
{getFilterLabel(opt.labelKey)}
</span>
))}
</div>
@@ -281,13 +289,16 @@ export default function ConversationsPage() {
<Avatar className="size-8 shrink-0 lg:size-9">
<AvatarImage src="" />
<AvatarFallback className="bg-primary/10 text-sm text-primary">
客
{t("conversation.customerAvatar")}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="min-w-0 truncate text-sm font-medium leading-tight">
{conversation.customerName || `客户 #${conversation.customerId || conversation.id}`}
{conversation.customerName ||
t("conversation.customerFallback", {
id: conversation.customerId || conversation.id,
})}
</p>
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] leading-none ${getCustomerOnlineClassName(
@@ -299,15 +310,17 @@ export default function ConversationsPage() {
conversation.customerOnline,
)}`}
/>
{conversation.customerOnline ? "用户在线" : "用户离线"}
{conversation.customerOnline
? t("conversation.customerOnline")
: t("conversation.customerOffline")}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
<span>渠道 #{conversation.channelId || "-"}</span>
<span>{t("conversation.channelNumber", { id: conversation.channelId || "-" })}</span>
{conversation.customerId ? (
<>
<span className="text-muted-foreground/60"> / </span>
<span>已关联客户</span>
<span>{t("conversation.linkedCustomer")}</span>
</>
) : null}
</p>
@@ -315,12 +328,14 @@ export default function ConversationsPage() {
</>
) : (
<div className="min-w-0">
<p className="truncate font-medium text-[14px] leading-tight">会话工作台</p>
<p className="truncate font-medium text-[14px] leading-tight">
{t("conversation.workbenchTitle")}
</p>
<p className="mt-0.5 truncate text-[14px] text-muted-foreground sm:text-[14px] lg:hidden">
打开菜单选择会话
{t("conversation.openMenuSelectConversation")}
</p>
<p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block">
请选择左侧会话开始处理消息
{t("conversation.selectConversationFromSidebar")}
</p>
</div>
)}
@@ -331,7 +346,7 @@ export default function ConversationsPage() {
size="icon"
className={`${workbenchIconButtonClassName} lg:hidden`}
disabled={!conversation}
aria-label="会话信息"
aria-label={t("conversation.conversationInfo")}
onClick={() => setMobileCustomerSheetOpen(true)}
>
<CircleUserRoundIcon className="size-4" />
@@ -355,21 +370,21 @@ export default function ConversationsPage() {
disabled={!conversation}
>
<FilePlus2Icon />
转工单
{t("conversation.createTicket")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTransferOpen(true)}
disabled={!conversation || conversation.status !== 3}
>
<ArrowRightLeftIcon />
转接会话
{t("conversation.transferConversation")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setCloseOpen(true)}
disabled={!conversation || conversation.status === 4}
>
<CircleXIcon />
关闭会话
{t("conversation.closeConversation")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -378,7 +393,11 @@ export default function ConversationsPage() {
size="icon"
className={`${workbenchIconButtonClassName} hidden lg:flex`}
onClick={handleInfoPanelToggle}
aria-label={infoPanelCollapsed ? "展开会话信息" : "收起会话信息"}
aria-label={
infoPanelCollapsed
? t("conversation.expandConversationInfo")
: t("conversation.collapseConversationInfo")
}
>
{infoPanelCollapsed ? (
<ChevronLeft className="size-4" />
@@ -396,11 +415,10 @@ export default function ConversationsPage() {
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="关闭会话列表"
aria-label={t("conversation.closeConversationList")}
className="fixed top-12 right-0 bottom-0 left-0 z-30 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
+69 -100
View File
@@ -33,13 +33,6 @@ import {
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,
@@ -57,41 +50,20 @@ import {
updateCustomerStatus,
type AdminCustomer,
} from "@/lib/api/customer";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
Gender,
GenderLabels,
Status,
StatusLabels,
} from "@/lib/generated/enums";
import { Gender, Status } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
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 }>,
fallback: string,
) {
return options.find((item) => item.value === value)?.label ?? "请选择";
return options.find((item) => item.value === value)?.label ?? fallback;
}
export default function DashboardCustomersPage() {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [genderFilterInput, setGenderFilterInput] = useState("all");
@@ -103,7 +75,7 @@ export default function DashboardCustomersPage() {
const [companyFilter, setCompanyFilter] = useState("0");
const [companyOptions, setCompanyOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部公司" },
{ value: "0", label: t("customer.allCompanies") },
]);
const [companyNameMap, setCompanyNameMap] = useState<Record<number, string>>(
{},
@@ -120,13 +92,30 @@ export default function DashboardCustomersPage() {
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const listStatusOptions = useMemo(
() => [
{ value: "all", label: t("status.all") },
{ value: String(Status.Ok), label: t("status.ok") },
{ value: String(Status.Disabled), label: t("status.disabled") },
],
[t],
);
const genderOptions = useMemo(
() => [
{ value: "all", label: t("customer.allGenders") },
{ value: String(Gender.Unknown), label: t("customerForm.genderUnknown") },
{ value: String(Gender.Male), label: t("customerForm.genderMale") },
{ value: String(Gender.Female), label: t("customerForm.genderFemale") },
],
[t],
);
useEffect(() => {
async function loadCompanies() {
try {
const data = await fetchCompanies({ status: 0, page: 1, limit: 500 });
const opts: ComboboxOption[] = [
{ value: "0", label: "全部公司" },
{ value: "0", label: t("customer.allCompanies") },
...data.results.map((item) => ({
value: String(item.id),
label: item.name,
@@ -143,7 +132,7 @@ export default function DashboardCustomersPage() {
}
}
void loadCompanies();
}, []);
}, [t]);
const loadData = useCallback(async () => {
setLoading(true);
@@ -158,11 +147,11 @@ export default function DashboardCustomersPage() {
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客户列表失败");
toast.error(error instanceof Error ? error.message : t("customer.loadFailed"));
} finally {
setLoading(false);
}
}, [companyFilter, genderFilter, keyword, limit, page, statusFilter]);
}, [companyFilter, genderFilter, keyword, limit, page, statusFilter, t]);
useEffect(() => {
void loadData();
@@ -171,9 +160,9 @@ export default function DashboardCustomersPage() {
const companyFilterLabel = useMemo(() => {
return (
companyOptions.find((item) => item.value === companyFilterInput)?.label ??
"全部公司"
t("customer.allCompanies")
);
}, [companyFilterInput, companyOptions]);
}, [companyFilterInput, companyOptions, t]);
function applyFilters() {
setKeyword(keywordInput);
@@ -217,14 +206,14 @@ export default function DashboardCustomersPage() {
await saveCustomerProfile(payload);
toast.success(
editingItem
? `已更新客户:${editingItem.name}`
: `已创建客户:${payload.name}`,
? t("customer.updated", { name: editingItem.name })
: t("customer.created", { name: payload.name }),
);
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客户失败");
toast.error(error instanceof Error ? error.message : t("customer.saveFailed"));
} finally {
setSaving(false);
}
@@ -235,10 +224,10 @@ export default function DashboardCustomersPage() {
try {
const nextStatus = item.status === 0 ? 1 : 0;
await updateCustomerStatus(item.id, nextStatus);
toast.success(`已${nextStatus === 0 ? "启用" : "禁用"}:${item.name}`);
toast.success(t(nextStatus === 0 ? "customer.enabled" : "customer.disabled", { name: item.name }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败");
toast.error(error instanceof Error ? error.message : t("customer.statusUpdateFailed"));
} finally {
setActionLoadingId(null);
}
@@ -248,17 +237,19 @@ export default function DashboardCustomersPage() {
setActionLoadingId(item.id);
try {
await deleteCustomer(item.id);
toast.success(`已删除客户:${item.name}`);
toast.success(t("customer.deleted", { name: item.name }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客户失败");
toast.error(error instanceof Error ? error.message : t("customer.deleteFailed"));
} finally {
setActionLoadingId(null);
}
}
function getGenderText(gender: number) {
return getEnumLabel(GenderLabels, gender as Gender);
if (gender === Gender.Male) return t("customerForm.genderMale");
if (gender === Gender.Female) return t("customerForm.genderFemale");
return t("customerForm.genderUnknown");
}
return (
@@ -268,7 +259,7 @@ export default function DashboardCustomersPage() {
actions={
<Button onClick={openCreateDialog}>
<PlusIcon />
新建
{t("customer.new")}
</Button>
}
>
@@ -278,60 +269,38 @@ export default function DashboardCustomersPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="姓名、手机、邮箱、公司、联系方式"
placeholder={t("customer.keywordPlaceholder")}
className="pl-9"
/>
</div>
<Select
<OptionCombobox
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>
options={genderOptions}
placeholder={getLabel(genderFilterInput, genderOptions, t("customer.select"))}
onChange={(v) => setGenderFilterInput(v)}
/>
<div className="w-full xl:w-56">
<OptionCombobox
value={companyFilterInput}
options={companyOptions}
placeholder={companyFilterLabel}
searchPlaceholder="搜索公司名称"
searchPlaceholder={t("customer.searchCompany")}
onChange={(v) => setCompanyFilterInput(v)}
/>
</div>
<Select
<OptionCombobox
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>
options={listStatusOptions}
placeholder={getLabel(statusFilterInput, listStatusOptions, t("customer.select"))}
onChange={(v) => setStatusFilterInput(v)}
/>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("customer.query")}
</Button>
</DashboardToolbar>
@@ -354,13 +323,13 @@ export default function DashboardCustomersPage() {
<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>
<TableHead>{t("customer.columnName")}</TableHead>
<TableHead className="w-20">{t("customer.columnGender")}</TableHead>
<TableHead>{t("customer.columnCompany")}</TableHead>
<TableHead>{t("customer.columnMobile")}</TableHead>
<TableHead>{t("customer.columnEmail")}</TableHead>
<TableHead className="w-24">{t("customer.columnStatus")}</TableHead>
<TableHead className="w-40">{t("customer.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -368,8 +337,8 @@ export default function DashboardCustomersPage() {
<DashboardTableStateRow
colSpan={8}
loading={loading}
loadingText="正在加载客户数据..."
emptyText="暂无客户数据"
loadingText={t("customer.loading")}
emptyText={t("customer.empty")}
/>
) : (
result.results.map((item) => {
@@ -397,7 +366,7 @@ export default function DashboardCustomersPage() {
<Badge
variant={item.status === 0 ? "default" : "secondary"}
>
{item.status === 0 ? "启用" : "禁用"}
{item.status === 0 ? t("status.ok") : t("status.disabled")}
</Badge>
</TableCell>
<TableCell>
@@ -407,7 +376,7 @@ export default function DashboardCustomersPage() {
size="sm"
onClick={() => openEditDialog(item)}
>
编辑
{t("customer.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
@@ -418,7 +387,7 @@ export default function DashboardCustomersPage() {
disabled={actionLoading}
/>
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("customer.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -431,16 +400,16 @@ export default function DashboardCustomersPage() {
onClick={() => void handleToggleStatus(item)}
>
{actionLoadingId === item.id ? (
"处理中..."
t("customer.processing")
) : item.status === 0 ? (
<>
<BanIcon />
禁用
{t("customer.disable")}
</>
) : (
<>
<CheckCircle2Icon />
启用
{t("customer.enable")}
</>
)}
</DropdownMenuItem>
@@ -450,7 +419,7 @@ export default function DashboardCustomersPage() {
onClick={() => void handleDelete(item)}
>
<Trash2Icon />
删除
{t("customer.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+10 -5
View File
@@ -1,15 +1,20 @@
"use client"
import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
import { useI18n } from "@/i18n/provider"
export default function DashboardHelpPage() {
const t = useI18n()
return (
<DashboardPlaceholder
eyebrow="Help"
title="帮助中心骨架"
description="帮助中心用于沉淀产品说明、权限约定、渠道接入流程与常见问题。"
title={t("help.title")}
description={t("help.description")}
nextSteps={[
"汇总项目开发规范与后台使用说明。",
"补充第三方接入流程图和配置校验清单。",
"后续可接入 markdown 或文档中心能力。",
t("help.step1"),
t("help.step2"),
t("help.step3"),
]}
/>
)
@@ -17,12 +17,15 @@ 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";
import { useI18n } from "@/i18n/provider";
import { getKnowledgeAnswerStatusLabel } from "@/lib/knowledge-i18n";
type DebugPanelProps = {
knowledgeBaseId: number | null;
};
export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
const t = useI18n();
const [question, setQuestion] = useState("");
const [topK, setTopK] = useState("5");
const [scoreThreshold, setScoreThreshold] = useState("0.2");
@@ -34,11 +37,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
async function handleSearch() {
if (!knowledgeBaseId) {
toast.error("请先选择知识库");
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
return;
}
if (!question.trim()) {
toast.error("请输入调试问题");
toast.error(t("knowledge.debugQuestionRequired"));
return;
}
@@ -52,9 +55,9 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
rerankLimit: Number(rerankLimit) || undefined,
});
setSearchResult(data);
toast.success(`检索完成,命中 ${data.hitCount} 条`);
toast.success(t("knowledge.searchCompleted", { count: data.hitCount }));
} catch (error) {
toast.error(error instanceof Error ? error.message : "检索失败");
toast.error(error instanceof Error ? error.message : t("knowledge.searchFailed"));
} finally {
setSearching(false);
}
@@ -62,11 +65,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
async function handleAnswer() {
if (!knowledgeBaseId) {
toast.error("请先选择知识库");
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
return;
}
if (!question.trim()) {
toast.error("请输入调试问题");
toast.error(t("knowledge.debugQuestionRequired"));
return;
}
@@ -80,9 +83,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
rerankLimit: Number(rerankLimit) || undefined,
});
setAnswerResult(data);
toast.success(`问答完成,状态:${data.answerStatusName}`);
toast.success(t("knowledge.answerCompleted", {
status: getKnowledgeAnswerStatusLabel(data.answerStatus, data.answerStatusName, t),
}));
} catch (error) {
toast.error(error instanceof Error ? error.message : "问答失败");
toast.error(error instanceof Error ? error.message : t("knowledge.answerFailed"));
} finally {
setAnswering(false);
}
@@ -94,32 +99,32 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
<Textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="输入问题,测试知识库召回和回答效果"
placeholder={t("knowledge.debugQuestionPlaceholder")}
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" />
<Input id="topk" value={topK} onChange={(event) => setTopK(event.target.value)} placeholder={t("knowledge.topKPlaceholder")} 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" />
<Label htmlFor="threshold" className="text-xs">{t("knowledge.scoreThreshold")}</Label>
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder={t("knowledge.scoreThresholdPlaceholder")} 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" />
<Label htmlFor="rerank" className="text-xs">{t("knowledge.rerankLimitLabel")}</Label>
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder={t("knowledge.rerankLimitPlaceholder")} 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 ? "检索中..." : "调试检索"}
{searching ? t("knowledge.searching") : t("knowledge.debugSearch")}
</Button>
<Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}>
<SparklesIcon className="mr-2 size-4" />
{answering ? "生成中..." : "调试问答"}
{answering ? t("knowledge.generating") : t("knowledge.debugAnswer")}
</Button>
</div>
</div>
@@ -131,12 +136,14 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<BotIcon className="size-4" />
回答结果
{t("knowledge.answerResult")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{answerResult.answerStatusName}</Badge>
<Badge variant="secondary">
{getKnowledgeAnswerStatusLabel(answerResult.answerStatus, answerResult.answerStatusName, t)}
</Badge>
<span className="text-xs text-muted-foreground">
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
</span>
@@ -146,7 +153,7 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
</div>
{answerResult.citations.length > 0 ? (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">引用来源</div>
<div className="text-xs font-medium text-muted-foreground">{t("knowledge.citationSources")}</div>
{answerResult.citations.map((citation) => (
<div
key={`${citation.documentId}-${citation.chunkNo}-${citation.sectionPath}`}
@@ -175,11 +182,11 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
{searchResult ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">检索命中</CardTitle>
<CardTitle className="text-sm">{t("knowledge.searchHits")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-muted-foreground">
命中 {searchResult.hitCount} 条 · {searchResult.latencyMs}ms
{t("knowledge.hitsSummary", { count: searchResult.hitCount, latency: searchResult.latencyMs })}
</div>
{searchResult.results.map((item) => (
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
@@ -221,5 +228,5 @@ function getSearchResultLabel(item: {
if (item.faqId && item.faqId > 0) {
return `FAQ ${item.faqId}`
}
return `文档 ${item.documentId ?? 0}`
return `Document ${item.documentId ?? 0}`
}
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form"
import { Controller, type Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { ProjectDialog } from "@/components/project-dialog"
@@ -23,6 +23,7 @@ import {
import {
KnowledgeDocumentContentType,
} from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
type DocumentEditDialogProps = {
open: boolean
@@ -39,18 +40,21 @@ const emptyForm: EditForm = {
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 TFunction = (key: string, values?: Record<string, string | number>) => string
type EditForm = z.infer<typeof knowledgeDocumentFormSchema>
const editFormResolver = zodResolver(knowledgeDocumentFormSchema as never) as Resolver<
z.input<typeof knowledgeDocumentFormSchema>,
undefined,
z.output<typeof knowledgeDocumentFormSchema>
>
function createKnowledgeDocumentFormSchema(t: TFunction) {
return z.object({
title: z.string().trim().min(1, t("knowledge.documentTitleRequired")).max(255, t("knowledge.documentTitleMax")),
contentType: z.string().trim().min(1, t("knowledge.contentTypeRequired")),
content: z.string().trim().min(1, t("knowledge.contentRequired")),
})
}
type EditForm = {
title: string
contentType: string
content: string
}
function buildForm(item: KnowledgeDocument | null): EditForm {
if (!item) {
@@ -112,13 +116,15 @@ function DocumentFormDialogBody({
onOpenChange,
onSubmit,
}: DocumentFormDialogBodyProps) {
const t = useI18n()
const formId = "knowledge-document-edit-form"
const [loading, setLoading] = useState(false)
const form = useForm<
z.input<typeof knowledgeDocumentFormSchema>,
undefined,
z.output<typeof knowledgeDocumentFormSchema>
>({
const knowledgeDocumentFormSchema = useMemo(() => createKnowledgeDocumentFormSchema(t), [t])
const editFormResolver = useMemo(
() => zodResolver(knowledgeDocumentFormSchema) as Resolver<EditForm>,
[knowledgeDocumentFormSchema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -163,7 +169,7 @@ function DocumentFormDialogBody({
<ProjectDialog
open={true}
onOpenChange={onOpenChange}
title={itemId ? "编辑文档" : "新建文档"}
title={itemId ? t("knowledge.editDocumentTitle") : t("knowledge.createDocumentTitle")}
size="xl"
allowFullscreen
footer={
@@ -174,26 +180,26 @@ function DocumentFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("knowledge.loading")}</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="doc-title">标题</FieldLabel>
<FieldLabel htmlFor="doc-title">{t("knowledge.documentTitle")}</FieldLabel>
<FieldContent>
<Input
id="doc-title"
placeholder="文档标题"
placeholder={t("knowledge.documentTitlePlaceholder")}
aria-invalid={!!errors.title}
{...register("title")}
/>
@@ -202,7 +208,7 @@ function DocumentFormDialogBody({
</Field>
<Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="doc-content">内容</FieldLabel>
<FieldLabel htmlFor="doc-content">{t("knowledge.content")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -223,7 +229,7 @@ function DocumentFormDialogBody({
shouldValidate: true,
})
}}
placeholder="请输入文档内容"
placeholder={t("knowledge.contentPlaceholder")}
disabled={saving}
/>
)}
@@ -8,7 +8,7 @@ import {
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
@@ -27,14 +27,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { OptionCombobox } from "@/components/option-combobox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
@@ -51,11 +45,10 @@ import {
type KnowledgeDocumentListItem,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import {
KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
StatusLabels
Status,
} from "@/lib/generated/enums";
import { cn, formatDateTime } from "@/lib/utils";
import { DocumentEditDialog } from "./document-edit";
@@ -73,15 +66,32 @@ export type DocumentListActionState = {
loading: boolean;
};
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
type TFunction = (key: string, values?: Record<string, string | number>) => string;
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels),
] as const;
function getStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allStatus") },
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
];
}
function getIndexStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allIndexStatus") },
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
];
}
function getIndexStatusLabel(status: string, t: TFunction) {
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
return status || "-";
}
function getIndexStatusBadgeVariant(status: string) {
switch (status) {
@@ -94,10 +104,10 @@ function getIndexStatusBadgeVariant(status: string) {
}
}
function renderIndexStatusBadge(item: KnowledgeDocumentListItem) {
function renderIndexStatusBadge(item: KnowledgeDocumentListItem, t: TFunction) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName}
{getIndexStatusLabel(item.indexStatus, t)}
</Badge>
)
@@ -125,6 +135,7 @@ function renderIndexStatusBadge(item: KnowledgeDocumentListItem) {
const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
@@ -149,6 +160,8 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
const loadData = useCallback(async (options?: {
keyword?: string;
@@ -181,11 +194,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
});
setDocuments(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载文档失败");
toast.error(error instanceof Error ? error.message : t("knowledge.loadDocumentsFailed"));
} finally {
setLoading(false);
}
}, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId, limit, page]);
}, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId, limit, page, t]);
useEffect(() => {
void loadData();
@@ -277,16 +290,16 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
id: editingItem.id,
...payload,
});
toast.success(`已更新文档:${editingItem.title}`);
toast.success(t("knowledge.documentUpdated", { title: editingItem.title }));
} else {
await createKnowledgeDocument(payload);
toast.success(`已创建文档:${payload.title}`);
toast.success(t("knowledge.documentCreated", { title: payload.title }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存文档失败");
toast.error(error instanceof Error ? error.message : t("knowledge.documentSaveFailed"));
} finally {
setSaving(false);
}
@@ -296,10 +309,10 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
try {
await deleteKnowledgeDocument(item.id);
toast.success(`已删除文档:${item.title}`);
toast.success(t("knowledge.documentDeleted", { title: item.title }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除文档失败");
toast.error(error instanceof Error ? error.message : t("knowledge.documentDeleteFailed"));
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
}
@@ -309,10 +322,10 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try {
await buildKnowledgeDocumentIndex(item.id);
toast.success(`已重建索引:${item.title}`);
toast.success(t("knowledge.documentIndexRebuilt", { title: item.title }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "重建索引失败");
toast.error(error instanceof Error ? error.message : t("knowledge.documentIndexRebuildFailed"));
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
}
@@ -322,7 +335,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
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>
<p>{t("knowledge.selectKnowledgeBaseForDocuments")}</p>
</div>
);
}
@@ -338,57 +351,26 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="搜索文档标题"
placeholder={t("knowledge.searchDocumentTitle")}
className="h-8 pl-8 text-xs"
/>
</div>
<Select
<OptionCombobox
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
onChange={handleStatusFilterChange}
options={statusOptions}
placeholder={t("knowledge.allStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
<OptionCombobox
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>
onChange={handleIndexStatusFilterChange}
options={indexStatusOptions}
placeholder={t("knowledge.allIndexStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
</div>
</div>
<div className="min-h-0 flex-1">
@@ -407,15 +389,15 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<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)}
{renderIndexStatusBadge(item, t)}
</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}
? t("knowledge.indexedAt", { time: formatDateTime(item.indexedAt) })
: getIndexStatusLabel(item.indexStatus, t)}
</span>
</div>
</div>
@@ -429,25 +411,25 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
className="size-6"
/>
}
aria-label={`更多操作 ${item.title}`}
aria-label={t("knowledge.moreActions", { name: 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" />
编辑
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -457,11 +439,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
编辑
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
@@ -469,7 +451,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -483,12 +465,12 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<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)}
{renderIndexStatusBadge(item, t)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `索引时间:${formatDateTime(item.indexedAt)}`
: item.indexError || item.indexStatusName}
? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
: item.indexError || getIndexStatusLabel(item.indexStatus, t)}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
@@ -503,25 +485,25 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
className="size-6"
/>
}
aria-label={`更多操作 ${item.title}`}
aria-label={t("knowledge.moreActions", { name: 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" />
编辑
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -530,11 +512,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
编辑
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
@@ -542,7 +524,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -550,7 +532,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
))}
{!loading && documents.results.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
没有匹配的文档
{t("knowledge.emptyDocuments")}
</div>
) : null}
</div>
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod/v4";
@@ -15,6 +15,7 @@ import {
type CreateKnowledgeFAQPayload,
type KnowledgeFAQ,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type FAQEditDialogProps = {
open: boolean;
@@ -25,20 +26,23 @@ type FAQEditDialogProps = {
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
const formSchema = z.object({
question: z.string().trim().min(1, "问题不能为空").max(500, "问题最多500个字符"),
answer: z.string().trim().min(1, "答案不能为空"),
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function createFormSchema(t: TFunction) {
return z.object({
question: z.string().trim().min(1, t("knowledge.faqQuestionRequired")).max(500, t("knowledge.faqQuestionMax")),
answer: z.string().trim().min(1, t("knowledge.faqAnswerRequired")),
similarQuestionsText: z.string(),
remark: z.string().trim().max(500, "备注最多500个字符"),
});
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
});
}
type EditForm = z.infer<typeof formSchema>;
const resolver = zodResolver(formSchema as never) as Resolver<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>;
type EditForm = {
question: string;
answer: string;
similarQuestionsText: string;
remark: string;
};
const emptyForm: EditForm = {
question: "",
@@ -113,13 +117,15 @@ function FAQEditDialogBody({
onOpenChange,
onSubmit,
}: FAQEditDialogBodyProps) {
const t = useI18n();
const [loading, setLoading] = useState(false);
const formId = "knowledge-faq-edit-form";
const form = useForm<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>({
const formSchema = useMemo(() => createFormSchema(t), [t]);
const resolver = useMemo(
() => zodResolver(formSchema) as Resolver<EditForm>,
[formSchema],
);
const form = useForm<EditForm>({
resolver,
defaultValues: emptyForm,
});
@@ -157,56 +163,56 @@ function FAQEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑FAQ" : "新建FAQ"}
title={itemId ? t("knowledge.editFAQTitle") : t("knowledge.createFAQTitle")}
allowFullscreen
size="xl"
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
取消
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">加载中...</div>
<div className="flex items-center justify-center py-12 text-muted-foreground">{t("knowledge.loading")}</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.question}>
<FieldLabel htmlFor="faq-question">标准问题</FieldLabel>
<FieldLabel htmlFor="faq-question">{t("knowledge.standardQuestion")}</FieldLabel>
<FieldContent>
<Input id="faq-question" placeholder="请输入标准问题" {...register("question")} />
<Input id="faq-question" placeholder={t("knowledge.questionPlaceholder")} {...register("question")} />
<FieldError errors={[errors.question]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.answer}>
<FieldLabel htmlFor="faq-answer">答案</FieldLabel>
<FieldLabel htmlFor="faq-answer">{t("knowledge.answer")}</FieldLabel>
<FieldContent>
<Textarea id="faq-answer" rows={8} placeholder="请输入FAQ答案" {...register("answer")} />
<Textarea id="faq-answer" rows={8} placeholder={t("knowledge.answerPlaceholder")} {...register("answer")} />
<FieldError errors={[errors.answer]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="faq-similar-questions">相似问题</FieldLabel>
<FieldLabel htmlFor="faq-similar-questions">{t("knowledge.similarQuestions")}</FieldLabel>
<FieldContent>
<Textarea
id="faq-similar-questions"
rows={5}
placeholder={"一行一个相似问题"}
placeholder={t("knowledge.similarQuestionsPlaceholder")}
{...register("similarQuestionsText")}
/>
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="faq-remark">备注</FieldLabel>
<FieldLabel htmlFor="faq-remark">{t("knowledge.remark")}</FieldLabel>
<FieldContent>
<Textarea id="faq-remark" rows={3} placeholder="备注" {...register("remark")} />
<Textarea id="faq-remark" rows={3} placeholder={t("knowledge.remarkPlaceholder")} {...register("remark")} />
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
@@ -215,4 +221,3 @@ function FAQEditDialogBody({
</ProjectDialog>
);
}
@@ -16,6 +16,7 @@ import {
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type FAQImportDialogProps = {
open: boolean;
@@ -39,24 +40,20 @@ type ParseResult = {
warnings: string[];
};
const templateContent = [
"question,answer,similarQuestions,remark",
'"如何重置密码?","进入个人设置后点击重置密码。","忘记密码|密码重置在哪里","账号类FAQ"',
'"支持哪些接入渠道?","目前支持网站组件、企业微信等渠道接入。","有哪些渠道|支持什么渠道","渠道说明"',
].join("\n");
type TFunction = (key: string, values?: Record<string, string | number>) => string;
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
question: "question",
"标准问题": "question",
"问题": "question",
answer: "answer",
"答案": "answer",
similarquestions: "similarQuestions",
"similarQuestions": "similarQuestions",
"相似问": "similarQuestions",
"相似问题": "similarQuestions",
remark: "remark",
"备注": "remark",
"\u6807\u51c6\u95ee\u9898": "question",
"\u95ee\u9898": "question",
"\u7b54\u6848": "answer",
"\u76f8\u4f3c\u95ee": "similarQuestions",
"\u76f8\u4f3c\u95ee\u9898": "similarQuestions",
"\u5907\u6ce8": "remark",
};
function normalizeHeader(value: string) {
@@ -134,10 +131,10 @@ function parseSimilarQuestions(value: string) {
.filter(Boolean);
}
function parseFAQFileContent(input: string): ParseResult {
function parseFAQFileContent(input: string, t: TFunction): ParseResult {
const table = parseDelimitedText(input);
if (table.length === 0) {
throw new Error("文件内容为空");
throw new Error(t("knowledge.fileEmpty"));
}
const headerRow = table[0];
@@ -150,7 +147,7 @@ function parseFAQFileContent(input: string): ParseResult {
}
if (!headerMap.has("question") || !headerMap.has("answer")) {
throw new Error("导入模板缺少 question/answer 列");
throw new Error(t("knowledge.missingFAQColumns"));
}
const rows: ParsedFAQRow[] = [];
@@ -168,7 +165,7 @@ function parseFAQFileContent(input: string): ParseResult {
continue;
}
if (!question || !answer) {
warnings.push(`第 ${rowNo} 行缺少问题或答案,已跳过`);
warnings.push(t("knowledge.skipRowMissingFAQ", { row: rowNo }));
continue;
}
@@ -185,6 +182,11 @@ function parseFAQFileContent(input: string): ParseResult {
}
function downloadTemplate() {
const templateContent = [
"question,answer,similarQuestions,remark",
'"How do I reset my password?","Open profile settings and choose Reset Password.","forgot password|where is reset password","Account FAQ"',
'"Which channels are supported?","Web chat and WeCom customer service channels are currently supported.","available channels|supported channels","Channel guide"',
].join("\n");
const blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -212,6 +214,7 @@ export function FAQImportDialog({
onImportingChange,
onImported,
}: FAQImportDialogProps) {
const t = useI18n();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const [rows, setRows] = useState<ParsedFAQRow[]>([]);
@@ -236,18 +239,18 @@ export function FAQImportDialog({
try {
const content = await file.text();
const parsed = parseFAQFileContent(content);
const parsed = parseFAQFileContent(content, t);
setFileName(file.name);
setRows(parsed.rows);
setWarnings(parsed.warnings);
if (parsed.rows.length === 0) {
toast.error("没有可导入的FAQ记录");
toast.error(t("knowledge.importNoRows"));
} else {
toast.success(`已解析 ${parsed.rows.length} 条FAQ`);
toast.success(t("knowledge.parsedFAQRows", { count: parsed.rows.length }));
}
} catch (error) {
resetState();
toast.error(error instanceof Error ? error.message : "解析导入文件失败");
toast.error(error instanceof Error ? error.message : t("knowledge.parseImportFailed"));
}
}
@@ -267,7 +270,10 @@ export function FAQImportDialog({
successCount += 1;
} catch (error) {
failedRows.push(
`第 ${row.rowNo} 行:${error instanceof Error ? error.message : "导入失败"}`
t("knowledge.importRowFailed", {
row: row.rowNo,
message: error instanceof Error ? error.message : t("knowledge.importFailed"),
})
);
}
}
@@ -275,10 +281,10 @@ export function FAQImportDialog({
await onImported();
if (successCount > 0) {
toast.success(`成功导入 ${successCount} 条FAQ`);
toast.success(t("knowledge.importSuccess", { count: successCount }));
}
if (failedRows.length > 0) {
toast.error(`有 ${failedRows.length} 条FAQ导入失败`);
toast.error(t("knowledge.importSomeFailed", { count: failedRows.length }));
setWarnings((current) => [...current, ...failedRows]);
return;
}
@@ -299,27 +305,31 @@ export function FAQImportDialog({
}
onOpenChange(nextOpen);
}}
title="导入FAQ"
description="上传 CSV 文件批量导入 FAQ。必填列为 question、answer;similarQuestions 使用 | 或换行分隔。"
title={t("knowledge.importFAQTitle")}
description={t("knowledge.importFAQDescription")}
size="lg"
footer={
<>
<Button type="button" variant="outline" onClick={() => downloadTemplate()}>
<DownloadIcon className="size-4" />
下载模板
{t("knowledge.downloadTemplate")}
</Button>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
取消
{t("knowledge.cancel")}
</Button>
<Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}>
{importing ? "导入中..." : `开始导入${rows.length > 0 ? ` (${rows.length})` : ""}`}
{importing
? t("knowledge.importing")
: rows.length > 0
? t("knowledge.startImportWithCount", { count: rows.length })
: t("knowledge.startImport")}
</Button>
</>
}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="faq-import-file">导入文件</FieldLabel>
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<Input
@@ -335,18 +345,18 @@ export function FAQImportDialog({
onClick={() => fileInputRef.current?.click()}
>
<FileUpIcon className="size-4" />
选择文件
{t("knowledge.chooseFile")}
</Button>
</div>
<FieldDescription>
支持 UTF-8 编码的 CSV 或制表符文本文件。
{t("knowledge.importFileDescription")}
</FieldDescription>
</FieldContent>
</Field>
{fileName ? (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
当前文件:{fileName}
{t("knowledge.currentFile", { name: fileName })}
</div>
) : null}
@@ -354,7 +364,7 @@ export function FAQImportDialog({
<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" />
导入提示
{t("knowledge.importHint")}
</div>
<ul className="space-y-1">
{warnings.map((item, index) => (
@@ -366,7 +376,7 @@ export function FAQImportDialog({
<div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium">
导入预览
{t("knowledge.importPreview")}
</div>
{previewRows.length > 0 ? (
<ScrollArea className="max-h-80">
@@ -374,7 +384,7 @@ export function FAQImportDialog({
{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>
<span className="text-muted-foreground">{t("knowledge.rowNumber", { row: row.rowNo })}</span>
</div>
<div>
<div className="font-medium">{row.question}</div>
@@ -383,16 +393,18 @@ export function FAQImportDialog({
</div>
</div>
<div className="text-muted-foreground">
相似问:{row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : "无"}
{t("knowledge.similarQuestionShort", {
value: row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : t("knowledge.none"),
})}
</div>
<div className="text-muted-foreground">备注:{row.remark || "无"}</div>
<div className="text-muted-foreground">{t("knowledge.remark")}:{row.remark || t("knowledge.none")}</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
上传文件后可预览前 5 条FAQ
{t("knowledge.previewAfterUpload")}
</div>
)}
</div>
@@ -6,7 +6,7 @@ import {
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
@@ -20,13 +20,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OptionCombobox } from "@/components/option-combobox";
import {
TableBody,
TableCell,
@@ -50,11 +44,10 @@ import {
type KnowledgeFAQ,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
} from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { formatDateTime } from "@/lib/utils";
import { FAQEditDialog } from "./faq-edit";
import { FAQImportDialog } from "./faq-import-dialog";
@@ -72,10 +65,23 @@ export type FAQListActionState = {
importing: boolean;
};
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels),
] as const;
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function getIndexStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allIndexStatus") },
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
];
}
function getIndexStatusLabel(status: string, t: TFunction) {
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
return status || "-";
}
function getIndexStatusBadgeVariant(status: string) {
switch (status) {
@@ -88,10 +94,10 @@ function getIndexStatusBadgeVariant(status: string) {
}
}
function renderIndexStatusBadge(item: KnowledgeFAQ) {
function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName}
{getIndexStatusLabel(item.indexStatus, t)}
</Badge>
);
@@ -120,6 +126,7 @@ export function FAQList({
knowledgeBaseId,
onActionStateChange,
}: FAQListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
@@ -139,6 +146,7 @@ export function FAQList({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
const loadData = useCallback(
async (options?: {
@@ -172,12 +180,12 @@ export function FAQList({
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载FAQ失败");
toast.error(error instanceof Error ? error.message : t("knowledge.loadFAQFailed"));
} finally {
setLoading(false);
}
},
[indexStatusFilter, keyword, knowledgeBaseId, limit, page],
[indexStatusFilter, keyword, knowledgeBaseId, limit, page, t],
);
useEffect(() => {
@@ -221,9 +229,9 @@ export function FAQList({
setDialogOpen(false);
setEditingItem(null);
await loadData();
toast.success("FAQ已保存");
toast.success(t("knowledge.faqSaved"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存FAQ失败");
toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed"));
} finally {
setSaving(false);
}
@@ -236,10 +244,10 @@ export function FAQList({
}));
try {
await deleteKnowledgeFAQ(item.id);
toast.success("FAQ已删除");
toast.success(t("knowledge.faqDeleted"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除FAQ失败");
toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed"));
} finally {
setActionLoadingMap((prev) => ({
...prev,
@@ -255,10 +263,10 @@ export function FAQList({
}));
try {
await buildKnowledgeFAQIndex(item.id);
toast.success("FAQ索引已重建");
toast.success(t("knowledge.faqIndexRebuilt"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "重建FAQ索引失败");
toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed"));
} finally {
setActionLoadingMap((prev) => ({
...prev,
@@ -270,7 +278,7 @@ export function FAQList({
if (!knowledgeBaseId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
请选择一个FAQ知识库查看FAQ
{t("knowledge.selectFAQBase")}
</div>
);
}
@@ -289,38 +297,24 @@ export function FAQList({
applyFilters();
}
}}
placeholder="按问题搜索FAQ"
placeholder={t("knowledge.searchFAQ")}
className="pl-9"
/>
</div>
<Select
<OptionCombobox
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>
onChange={(value) => setIndexStatusFilterInput(value ?? "all")}
options={indexStatusOptions}
placeholder={t("knowledge.allIndexStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
查询
{t("knowledge.query")}
</Button>
</div>
@@ -329,11 +323,11 @@ export function FAQList({
<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>
<TableHead>{t("knowledge.question")}</TableHead>
<TableHead>{t("knowledge.indexStatus")}</TableHead>
<TableHead>{t("knowledge.similarQuestions")}</TableHead>
<TableHead>{t("knowledge.updatedAt")}</TableHead>
<TableHead className="w-20 text-right">{t("knowledge.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -345,7 +339,7 @@ export function FAQList({
{item.answer}
</div>
</TableCell>
<TableCell>{renderIndexStatusBadge(item)}</TableCell>
<TableCell>{renderIndexStatusBadge(item, t)}</TableCell>
<TableCell>
{Array.isArray(item.similarQuestions)
? item.similarQuestions.length
@@ -362,12 +356,12 @@ export function FAQList({
setDialogOpen(true);
}}
>
编辑
{t("knowledge.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.question}`}
aria-label={t("knowledge.moreActions", { name: item.question })}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
@@ -377,8 +371,8 @@ export function FAQList({
>
<WrenchIcon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.rebuildIndex
? "重建中..."
: "重建索引"}
? t("knowledge.rebuilding")
: t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
@@ -386,8 +380,8 @@ export function FAQList({
>
<Trash2Icon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.delete
? "删除中..."
: "删除"}
? t("knowledge.deleting")
: t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -401,7 +395,7 @@ export function FAQList({
colSpan={5}
className="py-12 text-center text-sm text-muted-foreground"
>
当前知识库还没有FAQ
{t("knowledge.emptyFAQ")}
</TableCell>
</TableRow>
) : null}
@@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { useEffect, useMemo, useState } from "react";
import { Controller, type Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
@@ -15,27 +15,17 @@ import {
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 { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { useI18n } from "@/i18n/provider";
import {
KnowledgeAnswerMode,
KnowledgeAnswerModeLabels,
KnowledgeBaseType,
KnowledgeBaseTypeLabels,
KnowledgeChunkProvider,
KnowledgeChunkProviderLabels,
} from "@/lib/generated/enums";
type KnowledgeBaseEditDialogProps = {
@@ -61,29 +51,61 @@ const emptyForm: EditForm = {
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, "请选择回答模式"),
remark: z.string().trim().max(500, "备注最多500个字符"),
});
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type EditForm = z.infer<typeof knowledgeBaseFormSchema>;
const editFormResolver = zodResolver(
knowledgeBaseFormSchema as never,
) as Resolver<
z.input<typeof knowledgeBaseFormSchema>,
undefined,
z.output<typeof knowledgeBaseFormSchema>
>;
function createKnowledgeBaseFormSchema(t: TFunction) {
return z.object({
name: z.string().trim().min(1, t("knowledge.nameRequired")).max(100, t("knowledge.nameMax")),
description: z.string().trim().max(500, t("knowledge.descriptionMax")),
knowledgeType: z.string().trim().min(1, t("knowledge.typeRequired")),
defaultTopK: z.string().trim().min(1, t("knowledge.topKRequired")),
defaultScoreThreshold: z.string().trim().min(1, t("knowledge.scoreRequired")),
defaultRerankLimit: z.string().trim().min(1, t("knowledge.rerankRequired")),
chunkProvider: z.string().trim().min(1, t("knowledge.chunkProviderRequired")),
chunkTargetTokens: z.string().trim().min(1, t("knowledge.targetTokensRequired")),
chunkMaxTokens: z.string().trim().min(1, t("knowledge.maxTokensRequired")),
chunkOverlapTokens: z.string().trim().min(1, t("knowledge.overlapTokensRequired")),
answerMode: z.string().trim().min(1, t("knowledge.answerModeRequired")),
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
});
}
type EditForm = {
name: string;
description: string;
knowledgeType: string;
defaultTopK: string;
defaultScoreThreshold: string;
defaultRerankLimit: string;
chunkProvider: string;
chunkTargetTokens: string;
chunkMaxTokens: string;
chunkOverlapTokens: string;
answerMode: string;
remark: string;
};
function getKnowledgeTypeOptions(t: TFunction) {
return [
{ value: KnowledgeBaseType.Document, label: t("knowledge.typeDocument") },
{ value: KnowledgeBaseType.FAQ, label: t("knowledge.typeFAQ") },
];
}
function getChunkProviderOptions(t: TFunction) {
return [
{ value: KnowledgeChunkProvider.Fixed, label: t("knowledge.chunkFixed") },
{ value: KnowledgeChunkProvider.Structured, label: t("knowledge.chunkStructured") },
{ value: KnowledgeChunkProvider.Semantic, label: t("knowledge.chunkSemantic") },
];
}
function getAnswerModeOptions(t: TFunction) {
return [
{ value: String(KnowledgeAnswerMode.Strict), label: t("knowledge.answerStrict") },
{ value: String(KnowledgeAnswerMode.Assist), label: t("knowledge.answerAssist") },
];
}
function buildForm(item: KnowledgeBase | null): EditForm {
if (!item) {
@@ -161,13 +183,18 @@ function KnowledgeBaseFormDialogBody({
onOpenChange,
onSubmit,
}: KnowledgeBaseFormDialogBodyProps) {
const t = useI18n();
const formId = "knowledge-base-edit-form";
const [loading, setLoading] = useState(false);
const form = useForm<
z.input<typeof knowledgeBaseFormSchema>,
undefined,
z.output<typeof knowledgeBaseFormSchema>
>({
const knowledgeBaseFormSchema = useMemo(() => createKnowledgeBaseFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(knowledgeBaseFormSchema) as Resolver<EditForm>,
[knowledgeBaseFormSchema],
);
const knowledgeTypeOptions = useMemo(() => getKnowledgeTypeOptions(t), [t]);
const chunkProviderOptions = useMemo(() => getChunkProviderOptions(t), [t]);
const answerModeOptions = useMemo(() => getAnswerModeOptions(t), [t]);
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: buildForm(null),
});
@@ -228,7 +255,7 @@ function KnowledgeBaseFormDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑知识库" : "新建知识库"}
title={itemId ? t("knowledge.editBaseTitle") : t("knowledge.createBaseTitle")}
size="lg"
footer={
<>
@@ -238,17 +265,17 @@ function KnowledgeBaseFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving || loading}
>
取消
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">加载中...</div>
<div className="text-sm text-muted-foreground">{t("knowledge.loading")}</div>
</div>
) : (
<form
@@ -257,7 +284,7 @@ function KnowledgeBaseFormDialogBody({
className="space-y-4"
>
<Field data-invalid={!!errors.knowledgeType}>
<FieldLabel htmlFor="kb-knowledge-type">知识库类型</FieldLabel>
<FieldLabel htmlFor="kb-knowledge-type">{t("knowledge.knowledgeType")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -265,13 +292,10 @@ function KnowledgeBaseFormDialogBody({
render={({ field }) => (
<OptionCombobox
value={field.value}
options={getEnumOptions(KnowledgeBaseTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))}
placeholder="选择知识库类型"
searchPlaceholder="搜索知识库类型"
emptyText="没有匹配的知识库类型"
options={knowledgeTypeOptions}
placeholder={t("knowledge.selectKnowledgeType")}
searchPlaceholder={t("knowledge.searchKnowledgeType")}
emptyText={t("knowledge.emptyKnowledgeType")}
onChange={field.onChange}
/>
)}
@@ -281,11 +305,11 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="kb-name">名称</FieldLabel>
<FieldLabel htmlFor="kb-name">{t("knowledge.name")}</FieldLabel>
<FieldContent>
<Input
id="kb-name"
placeholder="知识库名称"
placeholder={t("knowledge.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -294,11 +318,11 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="kb-description">描述</FieldLabel>
<FieldLabel htmlFor="kb-description">{t("knowledge.description")}</FieldLabel>
<FieldContent>
<Textarea
id="kb-description"
placeholder="知识库描述"
placeholder={t("knowledge.descriptionPlaceholder")}
rows={3}
aria-invalid={!!errors.description}
{...register("description")}
@@ -310,7 +334,7 @@ function KnowledgeBaseFormDialogBody({
{!isFAQKnowledgeBase ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.chunkProvider}>
<FieldLabel htmlFor="kb-chunk-provider">分块策略</FieldLabel>
<FieldLabel htmlFor="kb-chunk-provider">{t("knowledge.chunkProvider")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -318,15 +342,10 @@ function KnowledgeBaseFormDialogBody({
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="没有匹配的分块策略"
options={chunkProviderOptions}
placeholder={t("knowledge.selectChunkProvider")}
searchPlaceholder={t("knowledge.searchChunkProvider")}
emptyText={t("knowledge.emptyChunkProvider")}
onChange={field.onChange}
/>
)}
@@ -336,7 +355,7 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.chunkTargetTokens}>
<FieldLabel htmlFor="kb-chunk-target-tokens">
目标 Token
{t("knowledge.targetToken")}
</FieldLabel>
<FieldContent>
<Input
@@ -351,7 +370,7 @@ function KnowledgeBaseFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkMaxTokens}>
<FieldLabel htmlFor="kb-chunk-max-tokens">最大 Token</FieldLabel>
<FieldLabel htmlFor="kb-chunk-max-tokens">{t("knowledge.maxToken")}</FieldLabel>
<FieldContent>
<Input
id="kb-chunk-max-tokens"
@@ -366,7 +385,7 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.chunkOverlapTokens}>
<FieldLabel htmlFor="kb-chunk-overlap-tokens">
重叠 Token
{t("knowledge.overlapToken")}
</FieldLabel>
<FieldContent>
<Input
@@ -385,7 +404,7 @@ function KnowledgeBaseFormDialogBody({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.defaultTopK}>
<FieldLabel htmlFor="kb-default-top-k">默认TopK</FieldLabel>
<FieldLabel htmlFor="kb-default-top-k">{t("knowledge.defaultTopK")}</FieldLabel>
<FieldContent>
<Input
id="kb-default-top-k"
@@ -400,7 +419,7 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.defaultScoreThreshold}>
<FieldLabel htmlFor="kb-default-score-threshold">
默认分数阈值
{t("knowledge.defaultScoreThreshold")}
</FieldLabel>
<FieldContent>
<Input
@@ -417,7 +436,7 @@ function KnowledgeBaseFormDialogBody({
</Field>
<Field data-invalid={!!errors.defaultRerankLimit}>
<FieldLabel htmlFor="kb-default-rerank-limit">
默认重排序限制
{t("knowledge.defaultRerankLimit")}
</FieldLabel>
<FieldContent>
<Input
@@ -432,37 +451,20 @@ function KnowledgeBaseFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.answerMode}>
<FieldLabel htmlFor="kb-answer-mode">回答模式</FieldLabel>
<FieldLabel htmlFor="kb-answer-mode">{t("knowledge.answerMode")}</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>
<OptionCombobox
value={field.value}
options={answerModeOptions}
placeholder={t("knowledge.selectAnswerMode")}
searchPlaceholder={t("knowledge.searchAnswerMode")}
emptyText={t("knowledge.emptyAnswerMode")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.answerMode]} />
@@ -471,11 +473,11 @@ function KnowledgeBaseFormDialogBody({
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="kb-remark">备注</FieldLabel>
<FieldLabel htmlFor="kb-remark">{t("knowledge.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="kb-remark"
placeholder="备注信息"
placeholder={t("knowledge.remarkPlaceholder")}
rows={2}
aria-invalid={!!errors.remark}
{...register("remark")}
@@ -29,7 +29,7 @@ import {
Trash2Icon,
} from "lucide-react";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -47,13 +47,7 @@ import {
} 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 { OptionCombobox } from "@/components/option-combobox";
import {
createKnowledgeBase,
deleteKnowledgeBase,
@@ -64,8 +58,8 @@ import {
type CreateKnowledgeBasePayload,
type KnowledgeBase,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { KnowledgeBaseType, StatusLabels } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { KnowledgeBaseType, Status } from "@/lib/generated/enums";
import { cn } from "@/lib/utils";
import { EditDialog } from "./knowledge-base-edit";
@@ -74,10 +68,16 @@ type KnowledgeBaseListProps = {
onSelectKnowledgeBase: (knowledgeBase: KnowledgeBase | null) => void;
};
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function getStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allStatus") },
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
];
}
type SortableKnowledgeBaseCardProps = {
item: KnowledgeBase;
@@ -89,6 +89,7 @@ type SortableKnowledgeBaseCardProps = {
onRebuildIndex: () => void;
deleteLoadingId: number | null;
rebuildIndexLoadingId: number | null;
t: TFunction;
};
function SortableKnowledgeBaseCard({
@@ -101,6 +102,7 @@ function SortableKnowledgeBaseCard({
onRebuildIndex,
deleteLoadingId,
rebuildIndexLoadingId,
t,
}: SortableKnowledgeBaseCardProps) {
const {
attributes,
@@ -157,7 +159,7 @@ function SortableKnowledgeBaseCard({
className="size-6 opacity-0 group-hover:opacity-100"
/>
}
aria-label={`更多操作 ${item.name}`}
aria-label={t("knowledge.moreActions", { name: item.name })}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
@@ -169,7 +171,7 @@ function SortableKnowledgeBaseCard({
}}
>
<PencilIcon className="mr-2 size-3.5" />
编辑
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -178,7 +180,7 @@ function SortableKnowledgeBaseCard({
}}
>
<RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"}
{rebuildIndexLoadingId === item.id ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -188,7 +190,7 @@ function SortableKnowledgeBaseCard({
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"}
{deleteLoadingId === item.id ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -201,7 +203,7 @@ function SortableKnowledgeBaseCard({
}}
>
<PencilIcon className="mr-2 size-3.5" />
编辑
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
@@ -210,7 +212,7 @@ function SortableKnowledgeBaseCard({
}}
>
<RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"}
{rebuildIndexLoadingId === item.id ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
@@ -220,7 +222,7 @@ function SortableKnowledgeBaseCard({
variant="destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"}
{deleteLoadingId === item.id ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -231,6 +233,7 @@ export function KnowledgeBaseList({
selectedKnowledgeBaseId,
onSelectKnowledgeBase,
}: KnowledgeBaseListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
@@ -245,6 +248,7 @@ export function KnowledgeBaseList({
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const sensors = useSensors(
useSensor(MouseSensor, {
@@ -268,11 +272,11 @@ export function KnowledgeBaseList({
});
setKnowledgeBases(data.results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载知识库失败");
toast.error(error instanceof Error ? error.message : t("knowledge.loadBasesFailed"));
} finally {
setLoading(false);
}
}, [keyword, statusFilter]);
}, [keyword, statusFilter, t]);
useEffect(() => {
void loadData();
@@ -340,16 +344,16 @@ export function KnowledgeBaseList({
const editingItem = knowledgeBases.find(
(item) => item.id === editingItemId,
);
toast.success(`已更新知识库:${editingItem?.name || payload.name}`);
toast.success(t("knowledge.baseUpdated", { name: editingItem?.name || payload.name }));
} else {
await createKnowledgeBase(payload);
toast.success(`已创建知识库:${payload.name}`);
toast.success(t("knowledge.baseCreated", { name: payload.name }));
}
setDialogOpen(false);
setEditingItemId(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存知识库失败");
toast.error(error instanceof Error ? error.message : t("knowledge.baseSaveFailed"));
} finally {
setSaving(false);
}
@@ -359,13 +363,13 @@ export function KnowledgeBaseList({
setDeleteLoadingId(item.id);
try {
await deleteKnowledgeBase(item.id);
toast.success(`已删除知识库:${item.name}`);
toast.success(t("knowledge.baseDeleted", { name: item.name }));
if (selectedKnowledgeBaseId === item.id) {
onSelectKnowledgeBase(null);
}
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除知识库失败");
toast.error(error instanceof Error ? error.message : t("knowledge.baseDeleteFailed"));
} finally {
setDeleteLoadingId(null);
}
@@ -375,10 +379,10 @@ export function KnowledgeBaseList({
setRebuildIndexLoadingId(item.id);
try {
await rebuildKnowledgeBaseIndex(item.id);
toast.success(`已开始重建知识库索引:${item.name}`);
toast.success(t("knowledge.rebuildStarted", { name: item.name }));
} catch (error) {
toast.error(
error instanceof Error ? error.message : "重建知识库索引失败",
error instanceof Error ? error.message : t("knowledge.rebuildFailed"),
);
} finally {
setRebuildIndexLoadingId(null);
@@ -404,12 +408,12 @@ export function KnowledgeBaseList({
try {
await updateKnowledgeBaseSort(nextResults.map((item) => item.id));
toast.success("知识库排序已更新");
toast.success(t("knowledge.sortUpdated"));
await loadData();
} catch (error) {
setKnowledgeBases(previousResults);
toast.error(
error instanceof Error ? error.message : "更新知识库排序失败",
error instanceof Error ? error.message : t("knowledge.sortUpdateFailed"),
);
} finally {
setSorting(false);
@@ -421,7 +425,7 @@ export function KnowledgeBaseList({
<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>
<h2 className="text-sm font-semibold">{t("knowledge.title")}</h2>
<div className="flex items-center gap-1">
<Button
variant="ghost"
@@ -451,33 +455,18 @@ export function KnowledgeBaseList({
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="搜索知识库"
placeholder={t("knowledge.searchBase")}
className="h-8 pl-8 text-xs"
/>
</div>
<Select
<OptionCombobox
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>
onChange={handleStatusFilterChange}
options={statusOptions}
placeholder={t("knowledge.allStatus")}
searchPlaceholder={t("knowledge.searchBase")}
emptyText={t("knowledge.emptyBases")}
/>
</div>
</div>
<ScrollArea className="flex-1">
@@ -503,13 +492,14 @@ export function KnowledgeBaseList({
onRebuildIndex={() => void handleRebuildIndex(item)}
deleteLoadingId={deleteLoadingId}
rebuildIndexLoadingId={rebuildIndexLoadingId}
t={t}
/>
))}
</SortableContext>
</DndContext>
{!loading && knowledgeBases.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
没有匹配的知识库
{t("knowledge.emptyBases")}
</div>
) : null}
</div>
@@ -19,6 +19,13 @@ import {
} from "@/components/ui/drawer"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { useI18n } from "@/i18n/provider"
import {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} from "@/lib/knowledge-i18n"
type RetrieveLogDetailDrawerProps = {
open: boolean
@@ -37,21 +44,23 @@ function safeParseJSON(value: string) {
}
}
function CitationList({ hits }: { hits: KnowledgeRetrieveHit[] }) {
type TFunction = (key: string, values?: Record<string, string | number>) => string
function CitationList({ hits, t }: { hits: KnowledgeRetrieveHit[]; t: TFunction }) {
const citations = hits.filter((item) => item.isCitation)
if (citations.length === 0) {
return <div className="text-sm text-muted-foreground">暂无引用来源</div>
return <div className="text-sm text-muted-foreground">{t("knowledge.noCitations")}</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>
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.sectionPath || item.title || "未记录章节"}
{item.sectionPath || item.title || t("knowledge.unrecordedSection")}
</div>
<div className="mt-2 text-sm leading-6 whitespace-pre-wrap text-foreground/90">
{item.snippet || "-"}
@@ -67,6 +76,7 @@ export function RetrieveLogDetailDrawer({
retrieveLogId,
onOpenChange,
}: RetrieveLogDetailDrawerProps) {
const t = useI18n()
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<KnowledgeRetrieveLogDetail | null>(null)
@@ -80,11 +90,11 @@ export function RetrieveLogDetailDrawer({
const data = await fetchKnowledgeRetrieveLog(retrieveLogId)
setDetail(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志详情失败")
toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogDetailFailed"))
} finally {
setLoading(false)
}
}, [retrieveLogId])
}, [retrieveLogId, t])
useEffect(() => {
if (open && retrieveLogId) {
@@ -102,105 +112,115 @@ export function RetrieveLogDetailDrawer({
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
<DrawerContent className="max-w-3xl">
<DrawerHeader>
<DrawerTitle>检索日志详情</DrawerTitle>
<DrawerTitle>{t("knowledge.retrieveLogDetail")}</DrawerTitle>
<DrawerDescription>
{detail?.log.question || (loading ? "加载中..." : "未找到检索日志")}
{detail?.log.question || (loading ? t("knowledge.loading") : t("knowledge.retrieveLogNotFound"))}
</DrawerDescription>
</DrawerHeader>
<ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6">
{!detail ? (
<div className="py-6 text-sm text-muted-foreground">
{loading ? "正在加载详情..." : "暂无详情数据"}
{loading ? t("knowledge.loadingDetail") : t("knowledge.emptyDetail")}
</div>
) : (
<div className="space-y-6 pb-6">
<section className="space-y-3">
<h3 className="text-sm font-semibold">请求信息</h3>
<h3 className="text-sm font-semibold">{t("knowledge.requestInfo")}</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="text-xs text-muted-foreground">{t("knowledge.title")}</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="text-xs text-muted-foreground">{t("knowledge.createdAt")}</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 className="text-xs text-muted-foreground">{t("knowledge.channelScene")}</div>
<div className="mt-1 text-sm">
{getKnowledgeRetrieveChannelLabel(detail.log.channel, detail.log.channelName, t)} /{" "}
{getKnowledgeRetrieveSceneLabel(detail.log.scene, detail.log.sceneName, t)}
</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="text-xs text-muted-foreground">{t("knowledge.originalQuestion")}</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="text-xs text-muted-foreground">{t("knowledge.rewriteQuestion")}</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="text-xs text-muted-foreground">{t("knowledge.answerContent")}</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>
<h3 className="text-sm font-semibold">{t("knowledge.retrieveStrategy")}</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="Chunk Provider"
value={detail.log.chunkProvider ? getKnowledgeChunkProviderLabel(detail.log.chunkProvider, t) : "-"}
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" value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold">结果概览</h3>
<h3 className="text-sm font-semibold">{t("knowledge.resultSummary")}</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={t("knowledge.answerStatus")}
value={getKnowledgeAnswerStatusLabel(detail.log.answerStatus, detail.log.answerStatusName, t)}
/>
<Metric label={t("knowledge.hitCount")} value={detail.log.hitCount} />
<Metric label={t("knowledge.citations")} value={detail.log.citationCount} />
<Metric label={t("knowledge.contextChunks")} 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={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
<Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
<Metric label={t("knowledge.totalMs")} 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 />
<Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
<Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold">引用来源</h3>
<CitationList hits={detail.hits} />
<h3 className="text-sm font-semibold">{t("knowledge.citationSources")}</h3>
<CitationList hits={detail.hits} t={t} />
</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>
<h3 className="text-sm font-semibold">{t("knowledge.hitDetails")}</h3>
<div className="text-xs text-muted-foreground">{t("knowledge.itemsCount", { count: 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>
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
<Badge variant={item.usedInAnswer ? "default" : "secondary"}>
{item.usedInAnswer ? "已入上下文" : "未入上下文"}
{item.usedInAnswer ? t("knowledge.usedInContext") : t("knowledge.notUsedInContext")}
</Badge>
{item.isCitation ? <Badge>引用</Badge> : null}
{item.isCitation ? <Badge>{t("knowledge.citations")}</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>{t("knowledge.section", { section: item.sectionPath || item.title || "-" })}</span>
<span>Chunk #{item.chunkNo}</span>
<span>Provider:{item.provider || "-"}</span>
<span>Score:{item.score.toFixed(4)}</span>
@@ -231,7 +251,7 @@ export function RetrieveLogDetailDrawer({
)
}
function getHitSourceLabel(item: KnowledgeRetrieveHit) {
function getHitSourceLabel(item: KnowledgeRetrieveHit, t: TFunction) {
if (item.faqQuestion) {
return item.faqQuestion
}
@@ -241,7 +261,7 @@ function getHitSourceLabel(item: KnowledgeRetrieveHit) {
if (item.faqId > 0) {
return `FAQ #${item.faqId}`
}
return `文档 #${item.documentId}`
return `${t("knowledge.document")} #${item.documentId}`
}
function Metric({
@@ -10,13 +10,18 @@ import {
type PageResult,
} from "@/lib/api/admin"
import {
KnowledgeAnswerStatusLabels,
KnowledgeChunkProviderLabels,
KnowledgeRetrieveChannelLabels,
KnowledgeRetrieveSceneLabels,
KnowledgeAnswerStatus,
KnowledgeRetrieveChannel,
KnowledgeRetrieveScene,
} from "@/lib/generated/enums"
import { getEnumOptions } from "@/lib/enums"
import {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} from "@/lib/knowledge-i18n"
import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
@@ -36,43 +41,70 @@ type RetrieveLogListProps = {
knowledgeBaseId: number | null
}
const channelOptions = [
{ value: "all", label: "全部渠道" },
...getEnumOptions(KnowledgeRetrieveChannelLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
const sceneOptions = [
{ value: "all", label: "全部场景" },
...getEnumOptions(KnowledgeRetrieveSceneLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
function getChannelOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allChannels") },
{ value: KnowledgeRetrieveChannel.IM, label: t("knowledge.channelIM") },
{ value: KnowledgeRetrieveChannel.AgentAssist, label: t("knowledge.channelAgentAssist") },
{ value: KnowledgeRetrieveChannel.API, label: t("knowledge.channelAPI") },
{ value: KnowledgeRetrieveChannel.Debug, label: t("knowledge.channelDebug") },
]
}
const answerStatusOptions = [
{ value: "all", label: "全部回答状态" },
...getEnumOptions(KnowledgeAnswerStatusLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
function getSceneOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allScenes") },
{ value: KnowledgeRetrieveScene.FirstResponse, label: t("knowledge.sceneFirstResponse") },
{ value: KnowledgeRetrieveScene.Assist, label: t("knowledge.sceneAssist") },
{ value: KnowledgeRetrieveScene.QA, label: t("knowledge.sceneQA") },
]
}
const providerOptions = [
{ value: "all", label: "全部切分策略" },
...getEnumOptions(KnowledgeChunkProviderLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
function getAnswerStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allAnswerStatus") },
{ value: String(KnowledgeAnswerStatus.Normal), label: t("knowledge.answerNormal") },
{ value: String(KnowledgeAnswerStatus.NoAnswer), label: t("knowledge.answerNoAnswer") },
{ value: String(KnowledgeAnswerStatus.Fallback), label: t("knowledge.answerFallback") },
{ value: String(KnowledgeAnswerStatus.Blocked), label: t("knowledge.answerBlocked") },
]
}
const rerankOptions = [
{ value: "all", label: "全部 Rerank" },
{ value: "1", label: "已启用 Rerank" },
{ value: "0", label: "未启用 Rerank" },
]
function getProviderOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allChunkProviders") },
{ value: "fixed", label: t("knowledge.chunkFixed") },
{ value: "structured", label: t("knowledge.chunkStructured") },
{ value: "faq", label: t("knowledge.chunkFAQ") },
{ value: "semantic", label: t("knowledge.chunkSemantic") },
]
}
function getRerankOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allRerank") },
{ value: "1", label: t("knowledge.rerankEnabled") },
{ value: "0", label: t("knowledge.rerankDisabled") },
]
}
function channelLabel(value: string, fallback: string, t: TFunction) {
return getKnowledgeRetrieveChannelLabel(value, fallback, t)
}
function sceneLabel(value: string, fallback: string, t: TFunction) {
return getKnowledgeRetrieveSceneLabel(value, fallback, t)
}
function answerStatusLabel(value: number, fallback: string, t: TFunction) {
return getKnowledgeAnswerStatusLabel(value, fallback, t)
}
function providerLabel(value: string, t: TFunction) {
return getKnowledgeChunkProviderLabel(value, t)
}
function getAnswerStatusVariant(status: number): "default" | "secondary" | "outline" | "destructive" {
switch (status) {
@@ -92,6 +124,7 @@ function getAnswerStatusVariant(status: number): "default" | "secondary" | "outl
export function RetrieveLogList({
knowledgeBaseId,
}: RetrieveLogListProps) {
const t = useI18n()
const [questionInput, setQuestionInput] = useState("")
const [question, setQuestion] = useState("")
const [channel, setChannel] = useState("all")
@@ -108,6 +141,11 @@ export function RetrieveLogList({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const channelOptions = useMemo(() => getChannelOptions(t), [t])
const sceneOptions = useMemo(() => getSceneOptions(t), [t])
const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t])
const providerOptions = useMemo(() => getProviderOptions(t), [t])
const rerankOptions = useMemo(() => getRerankOptions(t), [t])
const loadData = useCallback(async () => {
if (!knowledgeBaseId) {
@@ -131,11 +169,11 @@ export function RetrieveLogList({
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志失败")
toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogsFailed"))
} finally {
setLoading(false)
}
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene])
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene, t])
useEffect(() => {
void loadData()
@@ -149,13 +187,13 @@ export function RetrieveLogList({
const emptyStateText = useMemo(() => {
if (!knowledgeBaseId) {
return "请选择一个知识库查看检索日志"
return t("knowledge.selectBaseForLogs")
}
if (loading) {
return "正在加载检索日志..."
return t("knowledge.loadingRetrieveLogs")
}
return "当前知识库还没有检索日志"
}, [knowledgeBaseId, loading])
return t("knowledge.emptyRetrieveLogs")
}, [knowledgeBaseId, loading, t])
function applyFilters() {
setQuestion(questionInput)
@@ -194,16 +232,16 @@ export function RetrieveLogList({
value={questionInput}
onChange={(event) => setQuestionInput(event.target.value)}
onKeyDown={handleQuestionKeyDown}
placeholder="按问题关键字筛选"
placeholder={t("knowledge.filterQuestion")}
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={channel} options={channelOptions} placeholder={t("knowledge.selectChannel")} onChange={setChannel} />
<OptionCombobox value={scene} options={sceneOptions} placeholder={t("knowledge.selectScene")} onChange={setScene} />
<OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder={t("knowledge.answerStatus")} onChange={setAnswerStatus} />
<OptionCombobox value={chunkProvider} options={providerOptions} placeholder={t("knowledge.chunkStrategy")} onChange={setChunkProvider} />
<OptionCombobox value={rerankEnabled} options={rerankOptions} placeholder="Rerank" onChange={setRerankEnabled} />
<Button onClick={applyFilters}>筛选</Button>
<Button onClick={applyFilters}>{t("knowledge.filter")}</Button>
</div>
</div>
@@ -212,15 +250,15 @@ export function RetrieveLogList({
<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-42">{t("knowledge.time")}</TableHead>
<TableHead>{t("knowledge.question")}</TableHead>
<TableHead className="w-28">{t("knowledge.answerStatus")}</TableHead>
<TableHead className="w-24 text-right">{t("knowledge.hitCount")}</TableHead>
<TableHead className="w-24 text-right">TopScore</TableHead>
<TableHead className="w-28">Provider</TableHead>
<TableHead className="w-24">Rerank</TableHead>
<TableHead className="w-24 text-right">引用</TableHead>
<TableHead className="w-28 text-right">耗时</TableHead>
<TableHead className="w-24 text-right">{t("knowledge.citations")}</TableHead>
<TableHead className="w-28 text-right">{t("knowledge.duration")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -242,21 +280,21 @@ export function RetrieveLogList({
<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>
<span>{channelLabel(item.channel, item.channelName, t)}</span>
<span>{sceneLabel(item.scene, item.sceneName, t)}</span>
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={getAnswerStatusVariant(item.answerStatus)}>
{item.answerStatusName}
{answerStatusLabel(item.answerStatus, item.answerStatusName, t)}
</Badge>
</TableCell>
<TableCell className="text-right">{item.hitCount}</TableCell>
<TableCell className="text-right font-mono text-xs">{item.topScore.toFixed(4)}</TableCell>
<TableCell>{item.chunkProvider || "-"}</TableCell>
<TableCell>{item.rerankEnabled ? `是 (${item.rerankLimit})` : "否"}</TableCell>
<TableCell>{item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-"}</TableCell>
<TableCell>{item.rerankEnabled ? `${t("knowledge.yes")} (${item.rerankLimit})` : t("knowledge.no")}</TableCell>
<TableCell className="text-right">{item.citationCount}</TableCell>
<TableCell className="text-right">{item.latencyMs} ms</TableCell>
</TableRow>
+15 -13
View File
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import type { KnowledgeBase } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import {
Bug,
DownloadIcon,
@@ -27,6 +28,7 @@ import { KnowledgeBaseList } from "./_components/knowledge-base-list"
import { RetrieveLogList } from "./_components/retrieve-log-list"
export default function DashboardKnowledgeDocumentsPage() {
const t = useI18n()
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | null>(null)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
@@ -53,7 +55,7 @@ export default function DashboardKnowledgeDocumentsPage() {
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 ? "展开知识库列表" : "折叠知识库列表"}
aria-label={sidebarCollapsed ? t("knowledge.expandList") : t("knowledge.collapseList")}
>
{sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" />
@@ -67,8 +69,8 @@ export default function DashboardKnowledgeDocumentsPage() {
<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>
<TabsTrigger value="documents">{isFAQKnowledgeBase ? t("knowledge.faq") : t("knowledge.document")}</TabsTrigger>
<TabsTrigger value="retrieveLogs">{t("knowledge.retrieveLogs")}</TabsTrigger>
</TabsList>
{activeTab === "documents" && !isFAQKnowledgeBase && documentActionState ? (
<div className="ml-auto flex items-center gap-1">
@@ -78,7 +80,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7"
onClick={documentActionState.onRefresh}
disabled={documentActionState.loading}
aria-label="刷新文档"
aria-label={t("knowledge.refreshDocuments")}
>
<RefreshCwIcon className={documentActionState.loading ? "size-4 animate-spin" : "size-4"} />
</Button>
@@ -87,7 +89,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={() => documentActionState.onChangeViewMode("list")}
aria-label="列表布局"
aria-label={t("knowledge.listLayout")}
>
<LayoutListIcon className="size-4" />
</Button>
@@ -96,7 +98,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={() => documentActionState.onChangeViewMode("grid")}
aria-label="网格布局"
aria-label={t("knowledge.gridLayout")}
>
<LayoutGridIcon className="size-4" />
</Button>
@@ -105,7 +107,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={() => setDebugPanelOpen(true)}
aria-label="打开调试面板"
aria-label={t("knowledge.openDebugPanel")}
>
<Bug className="size-4" />
</Button>
@@ -114,7 +116,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={documentActionState.onCreate}
aria-label="新增文档"
aria-label={t("knowledge.newDocument")}
>
<PlusIcon className="size-4" />
</Button>
@@ -128,7 +130,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7"
onClick={faqActionState.onRefresh}
disabled={faqActionState.loading}
aria-label="刷新FAQ"
aria-label={t("knowledge.refreshFAQ")}
>
<RefreshCwIcon className={faqActionState.loading ? "size-4 animate-spin" : "size-4"} />
</Button>
@@ -138,7 +140,7 @@ export default function DashboardKnowledgeDocumentsPage() {
className="size-7"
onClick={faqActionState.onImport}
disabled={faqActionState.importing}
aria-label="导入FAQ"
aria-label={t("knowledge.importFAQ")}
>
<DownloadIcon className="size-4" />
</Button>
@@ -147,7 +149,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={() => setDebugPanelOpen(true)}
aria-label="打开调试面板"
aria-label={t("knowledge.openDebugPanel")}
>
<Bug className="size-4" />
</Button>
@@ -156,7 +158,7 @@ export default function DashboardKnowledgeDocumentsPage() {
size="icon"
className="size-7"
onClick={faqActionState.onCreate}
aria-label="新增FAQ"
aria-label={t("knowledge.newFAQ")}
>
<PlusIcon className="size-4" />
</Button>
@@ -187,7 +189,7 @@ export default function DashboardKnowledgeDocumentsPage() {
<Sheet open={debugPanelOpen} onOpenChange={setDebugPanelOpen}>
<SheetContent side="right" className="min-w-170">
<SheetHeader>
<SheetTitle>RAG 调试</SheetTitle>
<SheetTitle>{t("knowledge.ragDebug")}</SheetTitle>
</SheetHeader>
<DebugPanel knowledgeBaseId={selectedKnowledgeBase?.id ?? null} />
</SheetContent>
+4 -2
View File
@@ -9,6 +9,7 @@ import { AppSidebar } from "@/components/app-sidebar"
import { useAuth } from "@/components/auth-provider"
import { NotificationProvider } from "@/components/notification-provider"
import { SiteHeader } from "@/components/site-header"
import { useI18n } from "@/i18n/provider"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
export default function DashboardLayout({
@@ -16,6 +17,7 @@ export default function DashboardLayout({
}: {
children: ReactNode
}) {
const t = useI18n()
const { ready, session } = useAuth()
const pathname = usePathname()
const router = useRouter()
@@ -39,9 +41,9 @@ export default function DashboardLayout({
<Loader2Icon className="size-5 animate-spin" />
</div>
<div className="space-y-1">
<p className="text-base font-medium">正在校验登录态</p>
<p className="text-base font-medium">{t("auth.checkingSession")}</p>
<p className="text-sm text-muted-foreground">
将自动同步当前管理员信息与权限数据
{t("auth.syncingProfile")}
</p>
</div>
</div>
+10 -6
View File
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useRef } from "react"
import { toast } from "sonner"
import { exchangeOIDCTicket } from "@/lib/api/auth"
import { useI18n } from "@/i18n/provider"
export default function OIDCLoginCallbackPage() {
return (
@@ -15,6 +16,7 @@ export default function OIDCLoginCallbackPage() {
}
function OIDCLoginCallbackContent() {
const t = useI18n()
const router = useRouter()
const searchParams = useSearchParams()
const ranRef = useRef(false)
@@ -30,32 +32,34 @@ function OIDCLoginCallbackContent() {
const nextPath = next && next.startsWith("/") ? next : "/dashboard"
if (!ticket) {
toast.error("OIDC 登录票据不存在")
toast.error(t("auth.oidcMissingTicket"))
router.replace("/dashboard/login")
return
}
void exchangeOIDCTicket(ticket)
.then(() => {
toast.success("登录成功,正在进入系统")
toast.success(t("auth.loginSuccess"))
router.replace(nextPath)
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "OIDC 登录失败")
toast.error(error instanceof Error ? error.message : t("auth.oidcFailed"))
router.replace("/dashboard/login")
})
}, [router, searchParams])
}, [router, searchParams, t])
return <OIDCLoginCallbackFallback />
}
function OIDCLoginCallbackFallback() {
const t = useI18n()
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">OIDC 登录中</h1>
<h1 className="text-2xl font-semibold tracking-tight">{t("auth.oidcSigningIn")}</h1>
<p className="mt-3 text-sm text-muted-foreground">
正在校验登录票据并进入系统,请稍候。
{t("auth.checkingTicket")}
</p>
</div>
</div>
+20 -18
View File
@@ -1,9 +1,20 @@
"use client"
import { LoginForm } from "@/components/login-form"
import { LocaleSwitcher } from "@/components/locale-switcher"
import { useI18n } from "@/i18n/provider"
import { ShieldCheckIcon } from "lucide-react"
import Link from "next/link"
import { Suspense } from "react"
export default function LoginPage() {
const t = useI18n()
const heroPoints = [
t("auth.heroPoint1"),
t("auth.heroPoint2"),
t("auth.heroPoint3"),
]
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">
@@ -12,51 +23,42 @@ export default function LoginPage() {
<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
{t("auth.heroBadge")}
</div>
<div className="space-y-3">
<h2 className="max-w-lg text-5xl font-semibold tracking-tight">
贝壳AI客服
{t("auth.heroTitle")}
</h2>
<p className="max-w-xl text-sm leading-6 text-white/72">
懂问题,更懂用户;快响应,更能落地。
{t("auth.heroSubtitle")}
</p>
</div>
</div>
<div className="grid gap-3">
{[
{
text: "让每一次咨询,都通向可追踪的结果。",
},
{
text: "从接待到转派到复盘,一条链路跑到底。",
},
{
text: "多渠道接入,智能分配,人机协同提效。",
},
].map((item, index) => (
{heroPoints.map((text, 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>
<p className="text-sm leading-6 text-white/70">{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">
<div className="flex items-center justify-between gap-2">
<Link href="/dashboard/login" className="flex items-center gap-2 font-medium">
<img
src="/images/logo.svg"
alt="CS Agent"
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
CS Agent
{t("app.brand")}
</Link>
<LocaleSwitcher />
</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">
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useRef } from "react"
import { toast } from "sonner"
import { exchangeWxWorkTicket } from "@/lib/api/auth"
import { useI18n } from "@/i18n/provider"
export default function WxWorkLoginCallbackPage() {
return (
@@ -15,6 +16,7 @@ export default function WxWorkLoginCallbackPage() {
}
function WxWorkLoginCallbackContent() {
const t = useI18n()
const router = useRouter()
const searchParams = useSearchParams()
const ranRef = useRef(false)
@@ -30,21 +32,21 @@ function WxWorkLoginCallbackContent() {
const nextPath = next && next.startsWith("/") ? next : "/dashboard"
if (!ticket) {
toast.error("企业微信登录票据不存在")
toast.error(t("auth.wxworkMissingTicket"))
router.replace("/dashboard/login")
return
}
void exchangeWxWorkTicket(ticket)
.then(() => {
toast.success("登录成功,正在进入系统")
toast.success(t("auth.loginSuccess"))
router.replace(nextPath)
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "企业微信登录失败")
toast.error(error instanceof Error ? error.message : t("auth.wxworkFailed"))
router.replace("/dashboard/login")
})
}, [router, searchParams])
}, [router, searchParams, t])
return (
<WxWorkLoginCallbackFallback />
@@ -52,12 +54,14 @@ function WxWorkLoginCallbackContent() {
}
function WxWorkLoginCallbackFallback() {
const t = useI18n()
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("auth.wxworkSigningIn")}</h1>
<p className="mt-3 text-sm text-muted-foreground">
正在校验登录票据并进入系统,请稍候。
{t("auth.checkingTicket")}
</p>
</div>
</div>
+33 -51
View File
@@ -33,10 +33,12 @@ import {
type MCPToolCallResult,
type MCPToolInfo,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
const defaultServerCode = "";
export default function MCPDashboardPage() {
const t = useI18n();
const [serverCode, setServerCode] = useState(defaultServerCode);
const [servers, setServers] = useState<MCPServerInfo[]>([]);
const [connection, setConnection] = useState<MCPConnectionResult | null>(
@@ -59,9 +61,9 @@ export default function MCPDashboardPage() {
value: server.code,
label: server.enabled
? `${server.code} (${server.endpoint})`
: `${server.code} (disabled)`,
: `${server.code} (${t("mcp.disabled")})`,
})),
[servers],
[servers, t],
);
useEffect(() => {
@@ -76,7 +78,7 @@ export default function MCPDashboardPage() {
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "加载 MCP 服务失败",
error instanceof Error ? error.message : t("mcp.loadServersFailed"),
);
} finally {
setLoadingServers(false);
@@ -84,16 +86,16 @@ export default function MCPDashboardPage() {
}
void loadServers();
}, []);
}, [t]);
async function handleTestConnection() {
setTesting(true);
try {
const result = await testMCPConnection(serverCode.trim());
setConnection(result);
toast.success("MCP 连接成功");
toast.success(t("mcp.connectSuccess"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "MCP 连接失败");
toast.error(error instanceof Error ? error.message : t("mcp.connectFailed"));
} finally {
setTesting(false);
}
@@ -104,9 +106,9 @@ export default function MCPDashboardPage() {
try {
const result = await listMCPTools(serverCode.trim());
setTools(result);
toast.success(`已加载 ${result.length} 个工具`);
toast.success(t("mcp.toolsLoaded", { count: result.length }));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载工具失败");
toast.error(error instanceof Error ? error.message : t("mcp.loadToolsFailed"));
} finally {
setLoadingTools(false);
}
@@ -114,11 +116,11 @@ export default function MCPDashboardPage() {
async function handleCallTool() {
if (!activeTool) {
toast.error("请先选择一个工具");
toast.error(t("mcp.selectToolFirst"));
return;
}
if (argumentsError) {
toast.error("Arguments JSON 格式不合法");
toast.error(t("mcp.argumentsInvalid"));
return;
}
@@ -128,7 +130,7 @@ export default function MCPDashboardPage() {
? (JSON.parse(argumentsText) as Record<string, unknown>)
: {};
} catch {
toast.error("arguments 必须是合法 JSON");
toast.error(t("mcp.argumentsMustBeJson"));
return;
}
@@ -140,9 +142,9 @@ export default function MCPDashboardPage() {
arguments: parsedArguments,
});
setToolResult(result);
toast.success(result.isError ? "工具返回错误结果" : "工具调用成功");
toast.success(result.isError ? t("mcp.toolReturnedError") : t("mcp.toolCallSuccess"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "工具调用失败");
toast.error(error instanceof Error ? error.message : t("mcp.toolCallFailed"));
} finally {
setCallingTool(false);
}
@@ -164,25 +166,6 @@ export default function MCPDashboardPage() {
return (
<>
<DashboardPage className="gap-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> */}
<DashboardToolbar
actions={
<>
@@ -195,7 +178,7 @@ export default function MCPDashboardPage() {
) : (
<PlugZapIcon className="mr-2 size-4" />
)}
测试连接
{t("mcp.testConnection")}
</Button>
<Button
variant="outline"
@@ -207,20 +190,20 @@ export default function MCPDashboardPage() {
) : (
<WrenchIcon className="mr-2 size-4" />
)}
列出工具
{t("mcp.listTools")}
</Button>
</>
}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="shrink-0 text-sm font-medium">服务配置</span>
<span className="shrink-0 text-sm font-medium">{t("mcp.serverConfig")}</span>
<div className="min-w-[280px] max-w-[520px] flex-1">
<OptionCombobox
value={serverCode}
options={serverOptions}
placeholder="选择一个 MCP Server"
searchPlaceholder="搜索 serverCode"
emptyText="没有可用的 MCP Server"
placeholder={t("mcp.selectServer")}
searchPlaceholder={t("mcp.searchServer")}
emptyText={t("mcp.emptyServer")}
disabled={loadingServers}
onChange={(value) => {
setServerCode(value);
@@ -235,7 +218,7 @@ export default function MCPDashboardPage() {
{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>
<Badge>{t("mcp.connected")}</Badge>
<span className="font-medium">
{connection.serverName || "-"}
</span>
@@ -254,15 +237,15 @@ export default function MCPDashboardPage() {
{tools.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
暂无工具结果,先点击上方“列出工具”。
{t("mcp.emptyToolsHint")}
</div>
) : (
<DashboardTableShell>
<div>
<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>{t("mcp.toolName")}</div>
<div>{t("mcp.description")}</div>
<div className="text-right">{t("mcp.actions")}</div>
</div>
{tools.map((tool) => (
<div
@@ -280,7 +263,7 @@ export default function MCPDashboardPage() {
size="sm"
onClick={() => openToolDrawer(tool)}
>
查看
{t("mcp.view")}
</Button>
</div>
</div>
@@ -293,10 +276,9 @@ export default function MCPDashboardPage() {
<Drawer open={drawerOpen} direction="right" onOpenChange={setDrawerOpen}>
<DrawerContent className="min-w-3xl">
<DrawerHeader>
<DrawerTitle>{activeTool?.name || "工具详情"}</DrawerTitle>
<DrawerTitle>{activeTool?.name || t("mcp.toolDetail")}</DrawerTitle>
<DrawerDescription>
在这里查看工具详细信息,并使用当前选择的 MCP Server
直接做一次真实调用测试。
{t("mcp.detailDescription")}
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 space-y-4 overflow-y-auto px-4 pb-4">
@@ -312,7 +294,7 @@ export default function MCPDashboardPage() {
) : null}
</div>
<p className="mt-3 text-sm text-muted-foreground">
{activeTool.description || "暂无描述"}
{activeTool.description || t("mcp.noDescription")}
</p>
</div>
@@ -354,7 +336,7 @@ export default function MCPDashboardPage() {
) : (
<WrenchIcon className="mr-2 size-4" />
)}
测试工具
{t("mcp.testTool")}
</Button>
{toolResult ? (
<div className="space-y-4 rounded-lg border p-4">
@@ -364,7 +346,7 @@ export default function MCPDashboardPage() {
toolResult.isError ? "destructive" : "default"
}
>
{toolResult.isError ? "返回错误" : "调用成功"}
{toolResult.isError ? t("mcp.returnedError") : t("mcp.callSuccess")}
</Badge>
<span className="text-sm font-medium">
{toolResult.toolName}
@@ -390,7 +372,7 @@ export default function MCPDashboardPage() {
</div>
<DrawerFooter>
<Button variant="outline" onClick={() => setDrawerOpen(false)}>
关闭
{t("mcp.close")}
</Button>
</DrawerFooter>
</DrawerContent>
+21 -17
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
import { toast } from "sonner"
@@ -23,14 +23,10 @@ import {
} from "@/lib/api/notification"
import type { PageResult } from "@/lib/api/admin"
import { cn, formatDateTime } from "@/lib/utils"
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
{ value: "all", label: "全部" },
{ value: "unread", label: "未读" },
{ value: "read", label: "已读" },
]
import { useI18n } from "@/i18n/provider"
export default function DashboardNotificationsPage() {
const t = useI18n()
const router = useRouter()
const { refreshUnreadCount } = useNotifications()
const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all")
@@ -42,6 +38,14 @@ export default function DashboardNotificationsPage() {
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const readStatusOptions = useMemo<Array<{ value: NotificationReadStatus; label: string }>>(
() => [
{ value: "all", label: t("notification.all") },
{ value: "unread", label: t("notification.unread") },
{ value: "read", label: t("notification.read") },
],
[t]
)
const loadData = useCallback(async () => {
setLoading(true)
@@ -53,11 +57,11 @@ export default function DashboardNotificationsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载通知失败")
toast.error(error instanceof Error ? error.message : t("notification.loadFailed"))
} finally {
setLoading(false)
}
}, [limit, page, readStatus])
}, [limit, page, readStatus, t])
useEffect(() => {
void loadData()
@@ -73,7 +77,7 @@ export default function DashboardNotificationsPage() {
router.push(item.actionUrl)
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "打开通知失败")
toast.error(error instanceof Error ? error.message : t("notification.openFailed"))
}
}
@@ -83,9 +87,9 @@ export default function DashboardNotificationsPage() {
await markAllNotificationsRead()
await refreshUnreadCount()
await loadData()
toast.success("已全部标记为已读")
toast.success(t("notification.markAllReadSuccess"))
} catch (error) {
toast.error(error instanceof Error ? error.message : "全部已读失败")
toast.error(error instanceof Error ? error.message : t("notification.markAllReadFailed"))
} finally {
setActionLoading(false)
}
@@ -115,7 +119,7 @@ export default function DashboardNotificationsPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={cn(loading && "animate-spin")} />
刷新
{t("notification.refresh")}
</Button>
<Button
variant="outline"
@@ -123,7 +127,7 @@ export default function DashboardNotificationsPage() {
disabled={actionLoading || result.page.total === 0}
>
<CheckCheckIcon />
全部已读
{t("notification.markAllRead")}
</Button>
</>
}
@@ -164,8 +168,8 @@ export default function DashboardNotificationsPage() {
>
<div className="flex flex-wrap items-center gap-2">
<BellIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{item.title || "通知"}</span>
{unread ? <Badge>未读</Badge> : <Badge variant="outline">已读</Badge>}
<span className="font-medium">{item.title || t("notification.fallbackTitle")}</span>
{unread ? <Badge>{t("notification.unread")}</Badge> : <Badge variant="outline">{t("notification.read")}</Badge>}
<span className="ml-auto text-xs text-muted-foreground">
{formatDateTime(item.createdAt)}
</span>
@@ -179,7 +183,7 @@ export default function DashboardNotificationsPage() {
</div>
) : (
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
{loading ? "正在加载通知" : "暂无通知"}
{loading ? t("notification.loading") : t("notification.empty")}
</div>
)}
</DashboardTableShell>
+50 -26
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { KeyRoundIcon, RefreshCwIcon, RouteIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
@@ -28,16 +28,13 @@ import {
type AdminPermission,
type PageResult,
} from "@/lib/api/admin"
import { Status, StatusLabels } from "@/lib/generated/enums"
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
import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n"
export default function DashboardPermissionsPage() {
const t = useI18n()
const { locale } = useAppLocale()
const [keywordInput, setKeywordInput] = useState("")
const [groupNameInput, setGroupNameInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -51,6 +48,15 @@ export default function DashboardPermissionsPage() {
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const listStatusOptions = useMemo(
() => [
{ value: "all", label: t("status.all") },
{ value: String(Status.Ok), label: t("status.ok") },
{ value: String(Status.Disabled), label: t("status.disabled") },
{ value: String(Status.Deleted), label: t("status.deleted") },
],
[t]
)
const loadData = useCallback(async () => {
setLoading(true)
@@ -64,11 +70,11 @@ export default function DashboardPermissionsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载权限失败")
toast.error(error instanceof Error ? error.message : t("permission.loadFailed"))
} finally {
setLoading(false)
}
}, [groupName, keyword, limit, page, statusFilter])
}, [groupName, keyword, limit, page, statusFilter, t])
useEffect(() => {
void loadData()
@@ -103,7 +109,7 @@ export default function DashboardPermissionsPage() {
actions={
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
刷新
{t("permission.refresh")}
</Button>
}
>
@@ -113,7 +119,7 @@ export default function DashboardPermissionsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按权限名称/编码筛选"
placeholder={t("permission.filterKeyword")}
className="pl-9"
/>
</div>
@@ -121,20 +127,20 @@ export default function DashboardPermissionsPage() {
value={groupNameInput}
onChange={(event) => setGroupNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按分组筛选"
placeholder={t("permission.filterGroup")}
className="w-full sm:w-44"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
onChange={setStatusFilterInput}
placeholder="全部状态"
placeholder={t("status.all")}
options={[...listStatusOptions]}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("permission.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
@@ -155,11 +161,11 @@ export default function DashboardPermissionsPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>权限</TableHead>
<TableHead>编码</TableHead>
<TableHead>分组</TableHead>
<TableHead>接口</TableHead>
<TableHead>状态</TableHead>
<TableHead>{t("permission.columnPermission")}</TableHead>
<TableHead>{t("permission.columnCode")}</TableHead>
<TableHead>{t("permission.columnGroup")}</TableHead>
<TableHead>{t("permission.columnApi")}</TableHead>
<TableHead>{t("permission.columnStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -171,7 +177,9 @@ export default function DashboardPermissionsPage() {
<KeyRoundIcon className="size-4" />
</div>
<div>
<div className="font-medium">{item.name}</div>
<div className="font-medium">
{getPermissionDisplayName(item.code, item.name, locale)}
</div>
<div className="text-xs text-muted-foreground">{item.type}</div>
</div>
</div>
@@ -179,7 +187,7 @@ export default function DashboardPermissionsPage() {
<TableCell>
<Badge variant="outline">{item.code}</Badge>
</TableCell>
<TableCell>{item.groupName}</TableCell>
<TableCell>{getPermissionGroupName(item.groupName, locale)}</TableCell>
<TableCell>
<div className="flex items-start gap-2">
<Badge variant="secondary">{item.method || "ANY"}</Badge>
@@ -195,7 +203,7 @@ export default function DashboardPermissionsPage() {
<Badge
variant={item.status === Status.Ok ? "secondary" : "outline"}
>
{StatusLabels[item.status as Status] ?? String(item.status)}
{getStatusLabel(item.status, t)}
</Badge>
</TableCell>
</TableRow>
@@ -204,8 +212,8 @@ export default function DashboardPermissionsPage() {
<DashboardTableStateRow
colSpan={5}
loading={loading}
loadingText="正在加载权限数据..."
emptyText="没有匹配的权限数据"
loadingText={t("permission.loading")}
emptyText={t("permission.empty")}
/>
) : null}
</TableBody>
@@ -214,3 +222,19 @@ export default function DashboardPermissionsPage() {
</DashboardPage>
)
}
function getStatusLabel(
status: number,
t: (key: string, values?: Record<string, string | number>) => string
) {
if (status === Status.Ok) {
return t("status.ok")
}
if (status === Status.Disabled) {
return t("status.disabled")
}
if (status === Status.Deleted) {
return t("status.deleted")
}
return String(status)
}
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
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 {
@@ -14,21 +15,15 @@ import {
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 { getEnumOptions } from "@/lib/enums";
import { Status, StatusLabels } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
type QuickReplyFormDialogProps = {
open: boolean;
@@ -46,33 +41,39 @@ const emptyForm: EditForm = {
sortNo: "0",
};
const formStatusOptions = getEnumOptions(StatusLabels).filter(
(item) => Number(item.value) !== Status.Deleted,
);
type EditForm = {
groupName: string;
title: string;
content: string;
status: string;
sortNo: string;
};
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 的整数"),
});
function createSchema(t: (key: string) => string) {
return z.object({
groupName: z.string().trim().min(1, t("quickReply.groupNameRequired")),
title: z.string().trim().min(1, t("quickReply.titleRequired")),
content: z.string().trim().min(1, t("quickReply.contentRequired")),
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
message: t("quickReply.statusRequired"),
}),
sortNo: z
.string()
.trim()
.min(1, t("quickReply.sortRequired"))
.regex(/^\d+$/, t("quickReply.sortInvalid")),
});
}
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 getLocalizedStatusLabel(value: string | number, t: (key: string) => string) {
const status = Number(value) as Status;
if (status === Status.Disabled) {
return t("status.disabled");
}
if (status === Status.Deleted) {
return t("status.deleted");
}
return t("status.ok");
}
function buildForm(item: AdminQuickReply | null): EditForm {
@@ -137,8 +138,29 @@ function QuickReplyFormDialogBody({
onOpenChange,
onSubmit,
}: QuickReplyFormDialogBodyProps) {
const t = useI18n();
const formId = "quick-reply-edit-form";
const [loading, setLoading] = useState(false);
const quickReplyFormSchema = useMemo(() => createSchema(t), [t]);
const editFormResolver = useMemo(
() =>
zodResolver(quickReplyFormSchema as never) as Resolver<
z.input<typeof quickReplyFormSchema>,
undefined,
z.output<typeof quickReplyFormSchema>
>,
[quickReplyFormSchema],
);
const formStatusOptions = useMemo(
() =>
getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: getLocalizedStatusLabel(item.value, t),
})),
[t],
);
const form = useForm<
z.input<typeof quickReplyFormSchema>,
undefined,
@@ -183,7 +205,7 @@ function QuickReplyFormDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑" : "新建"}
title={itemId ? t("quickReply.editTitle") : t("quickReply.createTitle")}
size="md"
allowFullscreen
footer={
@@ -194,26 +216,26 @@ function QuickReplyFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("quickReply.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("quickReply.saving") : itemId ? t("quickReply.save") : t("quickReply.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("quickReply.loadingDetail")}</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.groupName}>
<FieldLabel htmlFor="quick-reply-group-name">分组名称</FieldLabel>
<FieldLabel htmlFor="quick-reply-group-name">{t("quickReply.groupName")}</FieldLabel>
<FieldContent>
<Input
id="quick-reply-group-name"
placeholder="例如:售前、售后、催单"
placeholder={t("quickReply.groupNamePlaceholder")}
aria-invalid={!!errors.groupName}
{...register("groupName")}
/>
@@ -221,11 +243,11 @@ function QuickReplyFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="quick-reply-title">标题</FieldLabel>
<FieldLabel htmlFor="quick-reply-title">{t("quickReply.title")}</FieldLabel>
<FieldContent>
<Input
id="quick-reply-title"
placeholder="请输入快捷回复标题"
placeholder={t("quickReply.titlePlaceholder")}
aria-invalid={!!errors.title}
{...register("title")}
/>
@@ -233,11 +255,11 @@ function QuickReplyFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="quick-reply-content">回复内容</FieldLabel>
<FieldLabel htmlFor="quick-reply-content">{t("quickReply.content")}</FieldLabel>
<FieldContent>
<Textarea
id="quick-reply-content"
placeholder="请输入回复内容"
placeholder={t("quickReply.contentPlaceholder")}
rows={6}
aria-invalid={!!errors.content}
{...register("content")}
@@ -247,49 +269,32 @@ function QuickReplyFormDialogBody({
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.status}>
<FieldLabel htmlFor="quick-reply-status">状态</FieldLabel>
<FieldLabel>{t("quickReply.columnStatus")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select
<OptionCombobox
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>
options={formStatusOptions}
placeholder={t("quickReply.statusRequired")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.status]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.sortNo}>
<FieldLabel htmlFor="quick-reply-sort-no">排序</FieldLabel>
<FieldLabel htmlFor="quick-reply-sort-no">{t("quickReply.columnSort")}</FieldLabel>
<FieldContent>
<Input
id="quick-reply-sort-no"
type="number"
min={0}
step={1}
placeholder="数字越大越靠前"
placeholder={t("quickReply.sortPlaceholder")}
aria-invalid={!!errors.sortNo}
{...register("sortNo")}
/>
+52 -40
View File
@@ -46,21 +46,23 @@ import {
type CreateAdminQuickReplyPayload,
type PageResult,
} from "@/lib/api/admin"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { getEnumOptions } from "@/lib/enums"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
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(status: Status, t: (key: string) => string) {
if (status === Status.Disabled) {
return t("status.disabled")
}
if (status === Status.Deleted) {
return t("status.deleted")
}
return t("status.ok")
}
export default function DashboardQuickRepliesPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("")
const [groupNameInput, setGroupNameInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -91,11 +93,21 @@ export default function DashboardQuickRepliesPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载快捷回复失败")
toast.error(error instanceof Error ? error.message : t("quickReply.loadFailed"))
} finally {
setLoading(false)
}
}, [groupName, keyword, limit, page, statusFilter])
}, [groupName, keyword, limit, page, statusFilter, t])
const listStatusOptions = [
{ value: "all", label: t("status.all") },
...getEnumOptions(StatusLabels)
.filter((item) => Number(item.value) !== Status.Deleted)
.map((item) => ({
value: String(item.value),
label: getStatusLabel(item.value as Status, t),
})),
]
useEffect(() => {
void loadData()
@@ -156,16 +168,16 @@ export default function DashboardQuickRepliesPage() {
id: editingItem.id,
...payload,
})
toast.success(`已更新快捷回复:${editingItem.title}`)
toast.success(t("quickReply.updated", { title: editingItem.title }))
} else {
await createQuickReply(payload)
toast.success(`已创建快捷回复:${payload.title}`)
toast.success(t("quickReply.created", { title: payload.title }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存快捷回复失败")
toast.error(error instanceof Error ? error.message : t("quickReply.saveFailed"))
} finally {
setSaving(false)
}
@@ -185,11 +197,11 @@ export default function DashboardQuickRepliesPage() {
status: nextStatus,
})
toast.success(
`已${nextStatus === Status.Ok ? "启用" : "禁用"}:${item.title}`
t(nextStatus === Status.Ok ? "quickReply.enabled" : "quickReply.disabled", { title: item.title })
)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("quickReply.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -199,10 +211,10 @@ export default function DashboardQuickRepliesPage() {
setActionLoadingId(item.id)
try {
await deleteQuickReply(item.id)
toast.success(`已删除快捷回复:${item.title}`)
toast.success(t("quickReply.deleted", { title: item.title }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除快捷回复失败")
toast.error(error instanceof Error ? error.message : t("quickReply.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -216,11 +228,11 @@ export default function DashboardQuickRepliesPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : undefined} />
刷新
{t("quickReply.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
新建快捷回复
{t("quickReply.new")}
</Button>
</>
}
@@ -231,7 +243,7 @@ export default function DashboardQuickRepliesPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按标题筛选"
placeholder={t("quickReply.filterTitle")}
className="pl-9"
/>
</div>
@@ -239,20 +251,20 @@ export default function DashboardQuickRepliesPage() {
value={groupNameInput}
onChange={(event) => setGroupNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按分组筛选"
placeholder={t("quickReply.filterGroup")}
className="w-full sm:w-44"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
onChange={setStatusFilterInput}
placeholder="全部状态"
placeholder={t("status.all")}
options={[...listStatusOptions]}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("quickReply.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
@@ -273,12 +285,12 @@ export default function DashboardQuickRepliesPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>快捷回复</TableHead>
<TableHead>分组</TableHead>
<TableHead>状态</TableHead>
<TableHead>排序</TableHead>
<TableHead>创建人</TableHead>
<TableHead className="w-[92px] text-right">操作</TableHead>
<TableHead>{t("quickReply.columnQuickReply")}</TableHead>
<TableHead>{t("quickReply.columnGroup")}</TableHead>
<TableHead>{t("quickReply.columnStatus")}</TableHead>
<TableHead>{t("quickReply.columnSort")}</TableHead>
<TableHead>{t("quickReply.columnCreator")}</TableHead>
<TableHead className="w-[92px] text-right">{t("quickReply.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -306,7 +318,7 @@ export default function DashboardQuickRepliesPage() {
item.status === Status.Ok ? "default" : "outline"
}
>
{getEnumLabel(StatusLabels, item.status as Status)}
{getStatusLabel(item.status as Status, t)}
</Badge>
</TableCell>
<TableCell>{item.sortNo}</TableCell>
@@ -318,12 +330,12 @@ export default function DashboardQuickRepliesPage() {
size="sm"
onClick={() => openEditDialog(item)}
>
编辑
{t("quickReply.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.title}`}
aria-label={t("quickReply.moreActions", { title: item.title })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -331,17 +343,17 @@ export default function DashboardQuickRepliesPage() {
<DropdownMenuItem onClick={() => void handleToggleStatus(item)}>
<RefreshCwIcon />
{actionLoadingId === item.id
? "处理中..."
? t("quickReply.processing")
: item.status === Status.Ok
? "禁用"
: "启用"}
? t("quickReply.disable")
: t("quickReply.enable")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("quickReply.deleting") : t("quickReply.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -353,8 +365,8 @@ export default function DashboardQuickRepliesPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载快捷回复..."
emptyText="没有匹配的快捷回复"
loadingText={t("quickReply.loading")}
emptyText={t("quickReply.empty")}
/>
) : null}
</TableBody>
@@ -25,6 +25,9 @@ import {
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import type { AdminPermission, AdminRole } from "@/lib/api/admin";
import { useAppLocale, useI18n } from "@/i18n/provider";
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n";
import { getRoleDisplayName } from "@/lib/role-i18n";
type AssignPermissionsDrawerProps = {
open: boolean;
@@ -104,6 +107,8 @@ function AssignPermissionsDrawerBody({
onOpenChange,
onSubmit,
}: AssignPermissionsDrawerBodyProps) {
const t = useI18n();
const { locale } = useAppLocale();
const [keyword, setKeyword] = useState("");
const form = useForm<
z.input<typeof assignPermissionsSchema>,
@@ -128,7 +133,7 @@ function AssignPermissionsDrawerBody({
const output = keyword.trim().toLowerCase();
const filtered = output
? permissions.filter((permission) =>
`${permission.name} ${permission.code} ${permission.groupName} ${permission.apiPath}`
`${permission.name} ${getPermissionDisplayName(permission.code, permission.name, locale)} ${permission.code} ${permission.groupName} ${getPermissionGroupName(permission.groupName, locale)} ${permission.apiPath}`
.toLowerCase()
.includes(output),
)
@@ -142,9 +147,9 @@ function AssignPermissionsDrawerBody({
groups.set(groupName, list);
});
return Array.from(groups.entries()).sort(([left], [right]) =>
left.localeCompare(right, "zh-CN"),
getPermissionGroupName(left, locale).localeCompare(getPermissionGroupName(right, locale)),
);
}, [keyword, permissions]);
}, [keyword, locale, permissions]);
async function onFormSubmit(values: AssignPermissionsForm) {
await onSubmit(values.permissionIds);
@@ -153,9 +158,12 @@ function AssignPermissionsDrawerBody({
return (
<DrawerContent className="min-w-3xl">
<DrawerHeader>
<DrawerTitle>分配权限</DrawerTitle>
<DrawerTitle>{t("role.assignTitle")}</DrawerTitle>
<DrawerDescription>
当前角色:{item?.name || "-"} {item?.code ? `(${item.code})` : ""}
{t("role.currentRole", {
name: item ? getRoleDisplayName(item.code, item.name, locale) : "-",
code: item?.code ? `(${item.code})` : "",
})}
</DrawerDescription>
</DrawerHeader>
<form
@@ -164,14 +172,14 @@ function AssignPermissionsDrawerBody({
>
<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>
<FieldLabel>{t("role.permissionList")}</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="搜索权限名称、编码、分组或接口"
placeholder={t("role.searchPermission")}
className="pl-9"
disabled={loading}
/>
@@ -186,15 +194,15 @@ function AssignPermissionsDrawerBody({
<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">
正在加载权限列表...
{t("role.loadingPermissions")}
</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>
<Badge variant="outline">{getPermissionGroupName(groupName, locale)}</Badge>
<span className="text-xs text-muted-foreground">
{list.length} 项
{t("role.permissionCount", { count: list.length })}
</span>
</div>
<div className="space-y-1">
@@ -225,7 +233,7 @@ function AssignPermissionsDrawerBody({
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{permission.name}
{getPermissionDisplayName(permission.code, permission.name, locale)}
</div>
<div className="text-sm text-muted-foreground">
{permission.code}
@@ -243,8 +251,8 @@ function AssignPermissionsDrawerBody({
}
>
{permission.status === 1
? "禁用"
: "启用"}
? t("status.disabled")
: t("status.ok")}
</Badge>
</label>
);
@@ -254,7 +262,7 @@ function AssignPermissionsDrawerBody({
))
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
没有匹配的权限
{t("role.noPermissions")}
</div>
)}
</div>
@@ -267,7 +275,7 @@ function AssignPermissionsDrawerBody({
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || loading || !item}>
{saving ? "保存中..." : "保存权限"}
{saving ? t("role.saving") : t("role.savePermissions")}
</Button>
<Button
type="button"
@@ -275,7 +283,7 @@ function AssignPermissionsDrawerBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("role.cancel")}
</Button>
</DrawerFooter>
</form>
+36 -32
View File
@@ -1,5 +1,6 @@
"use client"
import { useMemo } from "react"
import { Resolver, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod/v4"
@@ -22,6 +23,7 @@ import {
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { useI18n } from "@/i18n/provider"
type CreateRoleDrawerProps = {
open: boolean
@@ -30,23 +32,11 @@ type CreateRoleDrawerProps = {
onSubmit: (payload: CreateAdminRolePayload) => Promise<void>
}
const createFormSchema = z.object({
name: z.string().trim().min(1, "角色名称不能为空"),
code: z
.string()
.trim()
.min(1, "角色编码不能为空")
.regex(/^[A-Za-z][A-Za-z0-9:_-]*$/, "角色编码需以字母开头,仅支持字母、数字、冒号、下划线和短横线"),
remark: z.string().trim(),
})
type CreateForm = z.infer<typeof createFormSchema>
const createFormResolver = zodResolver(createFormSchema as never) as Resolver<
z.input<typeof createFormSchema>,
undefined,
z.output<typeof createFormSchema>
>
type CreateForm = {
name: string
code: string
remark: string
}
const emptyForm: CreateForm = {
name: "",
@@ -99,11 +89,25 @@ function CreateRoleDrawerBody({
onOpenChange,
onSubmit,
}: CreateRoleDrawerBodyProps) {
const form = useForm<
z.input<typeof createFormSchema>,
undefined,
z.output<typeof createFormSchema>
>({
const t = useI18n()
const createFormSchema = useMemo(
() =>
z.object({
name: z.string().trim().min(1, t("role.nameRequired")),
code: z
.string()
.trim()
.min(1, t("role.codeRequired"))
.regex(/^[A-Za-z][A-Za-z0-9:_-]*$/, t("role.codeInvalid")),
remark: z.string().trim(),
}),
[t]
)
const createFormResolver = useMemo(
() => zodResolver(createFormSchema as never) as Resolver<CreateForm>,
[createFormSchema]
)
const form = useForm<CreateForm>({
resolver: createFormResolver,
defaultValues: buildEmptyForm(),
})
@@ -122,8 +126,8 @@ function CreateRoleDrawerBody({
return (
<DrawerContent className="min-w-2xl">
<DrawerHeader>
<DrawerTitle>添加角色</DrawerTitle>
<DrawerDescription>创建后可在列表中分配权限和调整排序。</DrawerDescription>
<DrawerTitle>{t("role.createTitle")}</DrawerTitle>
<DrawerDescription>{t("role.createDescription")}</DrawerDescription>
</DrawerHeader>
<form
className="flex h-full flex-col"
@@ -131,11 +135,11 @@ function CreateRoleDrawerBody({
>
<div className="space-y-4 overflow-y-auto px-4 pb-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="create-role-name">角色名称</FieldLabel>
<FieldLabel htmlFor="create-role-name">{t("role.name")}</FieldLabel>
<FieldContent>
<Input
id="create-role-name"
placeholder="例如:客服主管"
placeholder={t("role.namePlaceholder")}
autoComplete="off"
aria-invalid={!!errors.name}
{...register("name")}
@@ -144,11 +148,11 @@ function CreateRoleDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="create-role-code">角色编码</FieldLabel>
<FieldLabel htmlFor="create-role-code">{t("role.code")}</FieldLabel>
<FieldContent>
<Input
id="create-role-code"
placeholder="例如:support_manager"
placeholder={t("role.codePlaceholder")}
autoComplete="off"
aria-invalid={!!errors.code}
{...register("code")}
@@ -157,11 +161,11 @@ function CreateRoleDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="create-role-remark">备注</FieldLabel>
<FieldLabel htmlFor="create-role-remark">{t("role.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="create-role-remark"
placeholder="可选"
placeholder={t("role.optional")}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
@@ -171,7 +175,7 @@ function CreateRoleDrawerBody({
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving}>
{saving ? "创建中..." : "创建角色"}
{saving ? t("role.creating") : t("role.create")}
</Button>
<Button
type="button"
@@ -179,7 +183,7 @@ function CreateRoleDrawerBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("role.cancel")}
</Button>
</DrawerFooter>
</form>
+31 -25
View File
@@ -62,6 +62,8 @@ import {
TableRow,
} from "@/components/ui/table"
import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { getRoleDisplayName } from "@/lib/role-i18n"
type SortableRoleRowProps = {
item: AdminRole
@@ -76,6 +78,9 @@ function SortableRoleRow({
actionLoading,
onAssignPermissions,
}: SortableRoleRowProps) {
const t = useI18n()
const { locale } = useAppLocale()
const displayName = getRoleDisplayName(item.code, item.name, locale)
const {
attributes,
listeners,
@@ -109,7 +114,7 @@ function SortableRoleRow({
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
aria-label={t("role.dragSort", { name: displayName })}
{...attributes}
{...listeners}
>
@@ -121,7 +126,7 @@ function SortableRoleRow({
<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 className="font-medium">{displayName}</div>
</div>
</TableCell>
<TableCell>
@@ -132,11 +137,11 @@ function SortableRoleRow({
</TableCell>
<TableCell>
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
{item.status === Status.Ok ? "启用" : "禁用"}
{item.status === Status.Ok ? t("status.ok") : t("status.disabled")}
</Badge>
{item.isSystem ? (
<Badge variant="outline" className="ml-2">
系统
{t("role.system")}
</Badge>
) : null}
</TableCell>
@@ -149,7 +154,7 @@ function SortableRoleRow({
onClick={() => onAssignPermissions(item)}
disabled={disabled || actionLoading}
>
{actionLoading ? "处理中..." : "分配权限"}
{actionLoading ? t("role.processing") : t("role.assignPermissions")}
</Button>
</TableCell>
</TableRow>
@@ -157,6 +162,7 @@ function SortableRoleRow({
}
export default function DashboardRolesPage() {
const t = useI18n()
const [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState(false)
const [creatingOpen, setCreatingOpen] = useState(false)
@@ -191,7 +197,7 @@ export default function DashboardRolesPage() {
try {
setResult(await fetchRoles({ limit: 200 }))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载角色失败")
toast.error(error instanceof Error ? error.message : t("role.loadFailed"))
} finally {
setLoading(false)
}
@@ -212,11 +218,11 @@ export default function DashboardRolesPage() {
setSavingCreate(true)
try {
const role = await createRole(payload)
toast.success(`已创建角色 ${role.name}`)
toast.success(t("role.created", { name: role.name }))
setCreatingOpen(false)
await loadRoles()
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建角色失败")
toast.error(error instanceof Error ? error.message : t("role.createFailed"))
} finally {
setSavingCreate(false)
}
@@ -240,7 +246,7 @@ export default function DashboardRolesPage() {
)
} catch (error) {
setAssigningRole(null)
toast.error(error instanceof Error ? error.message : "加载权限分配数据失败")
toast.error(error instanceof Error ? error.message : t("role.loadAssignFailed"))
} finally {
setAssignPermissionsLoading(false)
setActionLoadingId(null)
@@ -269,14 +275,14 @@ export default function DashboardRolesPage() {
try {
await updateRoleSort(nextResults.map((item) => item.id))
toast.success("角色排序已更新")
toast.success(t("role.sortUpdated"))
await loadRoles()
} catch (error) {
setResult((current) => ({
...current,
results: previousResults,
}))
toast.error(error instanceof Error ? error.message : "更新角色排序失败")
toast.error(error instanceof Error ? error.message : t("role.sortUpdateFailed"))
} finally {
setSorting(false)
}
@@ -301,13 +307,13 @@ export default function DashboardRolesPage() {
setSavingPermissions(true)
try {
await assignRolePermissions(assigningRole.id, permissionIds)
toast.success(`已更新角色 ${assigningRole.name} 的权限`)
toast.success(t("role.permissionsUpdated", { name: assigningRole.name }))
setAssigningRole(null)
setAssignPermissionOptions([])
setAssignPermissionIds([])
await loadRoles()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存权限分配失败")
toast.error(error instanceof Error ? error.message : t("role.savePermissionsFailed"))
} finally {
setSavingPermissions(false)
}
@@ -328,7 +334,7 @@ export default function DashboardRolesPage() {
disabled={loading || sorting}
>
<PlusIcon className="size-4" />
添加角色
{t("role.add")}
</Button>
<Button
variant="outline"
@@ -336,22 +342,22 @@ export default function DashboardRolesPage() {
disabled={loading || sorting}
>
<RefreshCwIcon className={cn((loading || sorting) && "animate-spin")} />
刷新
{t("role.refresh")}
</Button>
</>
}
>
<div className="text-sm text-muted-foreground">
拖拽行可调整角色顺序
{t("role.dragHint")}
</div>
</DashboardToolbar>
<DashboardTableShell
pagination={
<DashboardTableSummary>
<span>
第 {result.page.page} 页 / 每页 {result.page.limit} 条
{t("role.pageSummary", { page: result.page.page, limit: result.page.limit })}
</span>
<span>共 {result.page.total} 条记录</span>
<span>{t("pagination.total", { total: result.page.total })}</span>
</DashboardTableSummary>
}
>
@@ -364,11 +370,11 @@ export default function DashboardRolesPage() {
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-14"></TableHead>
<TableHead>角色</TableHead>
<TableHead>编码</TableHead>
<TableHead>状态</TableHead>
<TableHead>排序</TableHead>
<TableHead className="text-right">操作</TableHead>
<TableHead>{t("role.columnRole")}</TableHead>
<TableHead>{t("role.columnCode")}</TableHead>
<TableHead>{t("role.columnStatus")}</TableHead>
<TableHead>{t("role.columnSort")}</TableHead>
<TableHead className="text-right">{t("role.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -392,8 +398,8 @@ export default function DashboardRolesPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载角色数据..."
emptyText="暂无角色数据"
loadingText={t("role.loading")}
emptyText={t("role.empty")}
/>
) : null}
</TableBody>
+10 -5
View File
@@ -1,15 +1,20 @@
"use client"
import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
import { useI18n } from "@/i18n/provider"
export default function DashboardSettingsPage() {
const t = useI18n()
return (
<DashboardPlaceholder
eyebrow="Settings"
title="系统设置骨架"
description="系统设置页将管理认证参数、上传配置、基础信息与运行策略。"
title={t("settings.title")}
description={t("settings.description")}
nextSteps={[
"补充系统配置读取与保存接口。",
"按安全、存储、登录策略拆分设置区块。",
"增加敏感配置的脱敏显示和二次确认。",
t("settings.step1"),
t("settings.step2"),
t("settings.step3"),
]}
/>
)
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useMemo, useState } from "react"
import { Resolver, useForm } from "react-hook-form"
import { type Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { LoaderCircleIcon, PlayIcon } from "lucide-react"
import { toast } from "sonner"
@@ -29,6 +29,7 @@ import {
type SkillDebugRunPayload,
type SkillDebugRunResult,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type DebugDialogProps = {
open: boolean
@@ -37,19 +38,21 @@ type DebugDialogProps = {
onOpenChange: (open: boolean) => void
}
const debugFormSchema = z.object({
aiAgentId: z.string().trim().min(1, "请选择 AI Agent"),
type TFunction = (key: string, values?: Record<string, string | number>) => string
function createDebugFormSchema(t: TFunction) {
return z.object({
aiAgentId: z.string().trim().min(1, t("skillDefinition.agentRequired")),
conversationId: z.string().trim(),
userMessage: z.string().trim().min(1, "请输入用户消息"),
})
userMessage: z.string().trim().min(1, t("skillDefinition.messageRequired")),
})
}
type DebugForm = z.infer<typeof debugFormSchema>
const debugFormResolver = zodResolver(debugFormSchema as never) as Resolver<
z.input<typeof debugFormSchema>,
undefined,
z.output<typeof debugFormSchema>
>
type DebugForm = {
aiAgentId: string
conversationId: string
userMessage: string
}
const emptyForm: DebugForm = {
aiAgentId: "",
@@ -57,15 +60,17 @@ const emptyForm: DebugForm = {
userMessage: "",
}
const quickResumeActions = [
{ label: "确认", value: "确认" },
{ label: "取消", value: "取消" },
]
function getQuickResumeActions(t: TFunction) {
return [
{ label: t("skillDefinition.confirm"), value: t("skillDefinition.confirm") },
{ label: t("skillDefinition.reject"), value: t("skillDefinition.reject") },
]
}
function ResultBlock({
title,
value,
emptyText = "暂无数据",
emptyText,
}: {
title: string
value?: string
@@ -116,6 +121,7 @@ function DebugDialogBody({
skillName,
onOpenChange,
}: DebugDialogProps) {
const t = useI18n()
const formId = `skill-debug-form-${skillCode}`
const [running, setRunning] = useState(false)
const [resuming, setResuming] = useState(false)
@@ -123,11 +129,12 @@ function DebugDialogBody({
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>
>({
const debugFormSchema = useMemo(() => createDebugFormSchema(t), [t])
const debugFormResolver = useMemo(
() => zodResolver(debugFormSchema) as Resolver<DebugForm>,
[debugFormSchema],
)
const form = useForm<DebugForm>({
resolver: debugFormResolver,
defaultValues: emptyForm,
})
@@ -142,6 +149,7 @@ function DebugDialogBody({
} = form
const selectedAgentId = watch("aiAgentId")
const quickResumeActions = useMemo(() => getQuickResumeActions(t), [t])
useEffect(() => {
async function loadAIAgents() {
@@ -205,7 +213,7 @@ function DebugDialogBody({
setResumeResult(null)
setResumeMessage("")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Skill 调试失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.debugFailed"))
setResult(null)
} finally {
setRunning(false)
@@ -218,7 +226,7 @@ function DebugDialogBody({
return
}
if (!nextMessage) {
toast.error("请输入恢复消息")
toast.error(t("skillDefinition.resumeMessageRequired"))
return
}
const payload: SkillDebugResumePayload = {
@@ -237,7 +245,7 @@ function DebugDialogBody({
setResumeResult(data)
setResumeMessage(nextMessage)
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复调试失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.resumeFailed"))
setResumeResult(null)
} finally {
setResuming(false)
@@ -248,8 +256,8 @@ function DebugDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={`调试 Skill · ${skillName || skillCode}`}
description="强制指定当前 Skill,直接查看 route、tools、graph、HITL 和回复结果。"
title={t("skillDefinition.debugTitle", { name: skillName || skillCode })}
description={t("skillDefinition.debugDescription")}
size="xl"
allowFullscreen
footer={
@@ -260,11 +268,11 @@ function DebugDialogBody({
disabled={running}
onClick={() => onOpenChange(false)}
>
关闭
{t("skillDefinition.close")}
</Button>
<Button type="submit" form={formId} disabled={running}>
{running ? <LoaderCircleIcon className="animate-spin" /> : <PlayIcon />}
{running ? "调试中..." : "开始调试"}
{running ? t("skillDefinition.debugging") : t("skillDefinition.startDebug")}
</Button>
</>
}
@@ -272,7 +280,7 @@ function DebugDialogBody({
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">调试输入</CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.debugInput")}</CardTitle>
</CardHeader>
<CardContent>
<form id={formId} onSubmit={handleSubmit(onSubmit)} className="space-y-4">
@@ -283,9 +291,9 @@ function DebugDialogBody({
<OptionCombobox
value={selectedAgentId}
options={aiAgentOptions}
placeholder="选择 AI Agent"
searchPlaceholder="搜索 AI Agent"
emptyText="未找到 AI Agent"
placeholder={t("skillDefinition.selectAgent")}
searchPlaceholder={t("skillDefinition.searchAgent")}
emptyText={t("skillDefinition.emptyAgent")}
onChange={(value) =>
setValue("aiAgentId", value, { shouldValidate: true })
}
@@ -302,7 +310,7 @@ function DebugDialogBody({
id="skill-debug-conversation-id"
type="number"
min={0}
placeholder="可选,填已有会话 ID 以复用上下文"
placeholder={t("skillDefinition.conversationPlaceholder")}
aria-invalid={!!errors.conversationId}
{...register("conversationId")}
/>
@@ -318,22 +326,22 @@ function DebugDialogBody({
</FieldContent>
</Field>
<Field>
<FieldLabel>命中 Agent</FieldLabel>
<FieldLabel>{t("skillDefinition.matchedAgent")}</FieldLabel>
<FieldContent>
<Input
value={selectedAgent?.name || "未选择"}
value={selectedAgent?.name || t("skillDefinition.noAgentSelected")}
disabled
/>
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.userMessage}>
<FieldLabel htmlFor="skill-debug-user-message">用户消息</FieldLabel>
<FieldLabel htmlFor="skill-debug-user-message">{t("skillDefinition.userMessage")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-debug-user-message"
rows={5}
placeholder="输入一段用户消息,调试当前 Skill 的路由、工具和回复。"
placeholder={t("skillDefinition.userMessagePlaceholder")}
aria-invalid={!!errors.userMessage}
{...register("userMessage")}
/>
@@ -347,7 +355,7 @@ function DebugDialogBody({
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">调试摘要</CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.debugSummary")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
@@ -359,38 +367,38 @@ function DebugDialogBody({
<Badge variant="secondary">{result.interruptType}</Badge>
) : null}
{result?.interrupted ? (
<Badge>已中断</Badge>
<Badge>{t("skillDefinition.interrupted")}</Badge>
) : (
<Badge variant="outline">未中断</Badge>
<Badge variant="outline">{t("skillDefinition.notInterrupted")}</Badge>
)}
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Skill 名称</div>
<div className="text-xs text-muted-foreground">{t("skillDefinition.skillName")}</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 || "暂无"}
{result?.planReason || t("skillDefinition.none")}
</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 || "暂无"}
{result?.replyText || t("skillDefinition.none")}
</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 || "暂无"}
{result?.checkPointId || t("skillDefinition.none")}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">错误信息</div>
<div className="text-xs text-muted-foreground">{t("skillDefinition.errorMessage")}</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{result?.errorMessage || "暂无"}
{result?.errorMessage || t("skillDefinition.none")}
</div>
</div>
</div>
@@ -399,11 +407,11 @@ function DebugDialogBody({
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">工具视图</CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.toolView")}</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="text-xs text-muted-foreground">{t("skillDefinition.skillToolWhitelist")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{(result?.toolWhitelist ?? []).length > 0 ? (
result?.toolWhitelist.map((toolCode) => (
@@ -412,12 +420,12 @@ function DebugDialogBody({
</Badge>
))
) : (
<span className="text-muted-foreground">暂无</span>
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">本轮实际暴露工具</div>
<div className="text-xs text-muted-foreground">{t("skillDefinition.exposedTools")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{(result?.exposedToolCodes ?? []).length > 0 ? (
result?.exposedToolCodes.map((toolCode) => (
@@ -426,12 +434,12 @@ function DebugDialogBody({
</Badge>
))
) : (
<span className="text-muted-foreground">暂无</span>
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">本轮实际调用工具</div>
<div className="text-xs text-muted-foreground">{t("skillDefinition.invokedTools")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{(result?.invokedToolCodes ?? []).length > 0 ? (
result?.invokedToolCodes.map((toolCode) => (
@@ -440,7 +448,7 @@ function DebugDialogBody({
</Badge>
))
) : (
<span className="text-muted-foreground">暂无</span>
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)}
</div>
</div>
@@ -449,29 +457,29 @@ function DebugDialogBody({
</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} />
<ResultBlock title="Skill Route Trace" value={result?.skillRouteTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Tool Search Trace" value={result?.toolSearchTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Graph Tool Trace" value={result?.graphToolTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Trace Data" value={result?.traceData} emptyText={t("skillDefinition.emptyData")} />
</div>
{result?.interrupted && result.checkPointId ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">恢复调试</CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.resumeDebug")}</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="text-xs text-muted-foreground">{t("skillDefinition.currentCheckpoint")}</div>
<div className="mt-1 break-all">{result.checkPointId}</div>
</div>
<Field>
<FieldLabel htmlFor="skill-debug-resume-message">恢复消息</FieldLabel>
<FieldLabel htmlFor="skill-debug-resume-message">{t("skillDefinition.resumeMessage")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-debug-resume-message"
rows={3}
placeholder="输入确认、取消或其他恢复消息"
placeholder={t("skillDefinition.resumePlaceholder")}
value={resumeMessage}
onChange={(event) => setResumeMessage(event.target.value)}
/>
@@ -499,7 +507,7 @@ function DebugDialogBody({
) : (
<PlayIcon />
)}
{resuming ? "恢复中..." : "恢复调试"}
{resuming ? t("skillDefinition.resuming") : t("skillDefinition.resumeDebugAction")}
</Button>
</div>
</CardContent>
@@ -510,7 +518,7 @@ function DebugDialogBody({
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">恢复结果</CardTitle>
<CardTitle className="text-sm">{t("skillDefinition.resumeResult")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
@@ -522,34 +530,34 @@ function DebugDialogBody({
<Badge variant="secondary">{resumeResult.interruptType}</Badge>
) : null}
{resumeResult.interrupted ? (
<Badge>仍在等待确认</Badge>
<Badge>{t("skillDefinition.stillWaiting")}</Badge>
) : (
<Badge variant="outline">已恢复完成</Badge>
<Badge variant="outline">{t("skillDefinition.resumeCompleted")}</Badge>
)}
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">恢复消息</div>
<div className="text-xs text-muted-foreground">{t("skillDefinition.resumeMessage")}</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{resumeMessage || "暂无"}
{resumeMessage || t("skillDefinition.none")}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">恢复回复</div>
<div className="text-xs text-muted-foreground">{t("skillDefinition.resumeReply")}</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{resumeResult.replyText || "暂无"}
{resumeResult.replyText || t("skillDefinition.none")}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">恢复 Plan Reason</div>
<div className="text-xs text-muted-foreground">Resume Plan Reason</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{resumeResult.planReason || "暂无"}
{resumeResult.planReason || t("skillDefinition.none")}
</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} />
<ResultBlock title="Resume Tool Search Trace" value={resumeResult.toolSearchTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Resume Graph Tool Trace" value={resumeResult.graphToolTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title="Resume Trace Data" value={resumeResult.traceData} emptyText={t("skillDefinition.emptyData")} />
</div>
) : null}
</div>
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { Resolver, useForm } from "react-hook-form";
import { type Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
@@ -23,7 +23,9 @@ import {
type MCPToolCatalogItem,
type SkillDefinition,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type SkillEditDialogProps = {
open: boolean;
@@ -42,25 +44,29 @@ const emptyForm: EditForm = {
remark: "",
};
const skillFormSchema = z.object({
function createSkillFormSchema(t: TFunction) {
return z.object({
code: z
.string()
.trim()
.min(1, "Skill 编码不能为空")
.regex(/^[a-zA-Z0-9_-]+$/, "Skill 编码仅支持字母、数字、下划线和中划线"),
name: z.string().trim().min(1, "Skill 名称不能为空"),
.min(1, t("skillDefinition.codeRequired"))
.regex(/^[a-zA-Z0-9_-]+$/, t("skillDefinition.codeInvalid")),
name: z.string().trim().min(1, t("skillDefinition.nameRequired")),
description: z.string().trim(),
instruction: z.string().trim().min(1, "技能说明不能为空"),
instruction: z.string().trim().min(1, t("skillDefinition.instructionRequired")),
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>
>;
type EditForm = {
code: string;
name: string;
description: string;
instruction: string;
examplesText: string;
remark: string;
};
function buildForm(item: SkillDefinition | null): EditForm {
if (!item) {
@@ -127,6 +133,7 @@ function SkillEditDialogBody({
onOpenChange,
onSubmit,
}: SkillEditDialogBodyProps) {
const t = useI18n();
const formId = "skill-definition-edit-form";
const [loading, setLoading] = useState(false);
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
@@ -134,11 +141,12 @@ function SkillEditDialogBody({
string[]
>([]);
const [toolCodeToAdd, setToolCodeToAdd] = useState("");
const form = useForm<
z.input<typeof skillFormSchema>,
undefined,
z.output<typeof skillFormSchema>
>({
const skillFormSchema = useMemo(() => createSkillFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(skillFormSchema) as Resolver<EditForm>,
[skillFormSchema],
);
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
@@ -237,7 +245,7 @@ function SkillEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑" : "新建"}
title={itemId ? t("skillDefinition.editTitle") : t("skillDefinition.createTitle")}
size="xl"
allowFullscreen
footer={
@@ -248,17 +256,17 @@ function SkillEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("skillDefinition.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("skillDefinition.saving") : itemId ? t("skillDefinition.save") : t("skillDefinition.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("skillDefinition.loading")}</div>
</div>
) : (
<form
@@ -268,11 +276,11 @@ function SkillEditDialogBody({
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="skill-code">编码</FieldLabel>
<FieldLabel htmlFor="skill-code">{t("skillDefinition.code")}</FieldLabel>
<FieldContent>
<Input
id="skill-code"
placeholder="例如:refund_skill"
placeholder={t("skillDefinition.codePlaceholder")}
aria-invalid={!!errors.code}
{...register("code")}
/>
@@ -280,11 +288,11 @@ function SkillEditDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="skill-name">名称</FieldLabel>
<FieldLabel htmlFor="skill-name">{t("skillDefinition.name")}</FieldLabel>
<FieldContent>
<Input
id="skill-name"
placeholder="例如:退款处理"
placeholder={t("skillDefinition.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -294,12 +302,12 @@ function SkillEditDialogBody({
</div>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="skill-description">描述</FieldLabel>
<FieldLabel htmlFor="skill-description">{t("skillDefinition.description")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-description"
rows={3}
placeholder="描述这个 Skill 的用途、边界和适用场景"
placeholder={t("skillDefinition.descriptionPlaceholder")}
aria-invalid={!!errors.description}
{...register("description")}
/>
@@ -308,12 +316,12 @@ function SkillEditDialogBody({
</Field>
<Field data-invalid={!!errors.instruction}>
<FieldLabel htmlFor="skill-instruction">技能说明</FieldLabel>
<FieldLabel htmlFor="skill-instruction">{t("skillDefinition.instruction")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-instruction"
rows={12}
placeholder="请输入 Skill 文档内容,描述目标、步骤、工具使用规则和边界。"
placeholder={t("skillDefinition.instructionPlaceholder")}
aria-invalid={!!errors.instruction}
{...register("instruction")}
/>
@@ -322,12 +330,12 @@ function SkillEditDialogBody({
</Field>
<Field data-invalid={!!errors.examplesText}>
<FieldLabel htmlFor="skill-examples">示例问法</FieldLabel>
<FieldLabel htmlFor="skill-examples">{t("skillDefinition.examples")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-examples"
rows={5}
placeholder={"每行一个典型用户问法,例如:\n我要申请退款\n帮我查下订单"}
placeholder={t("skillDefinition.examplesPlaceholder")}
aria-invalid={!!errors.examplesText}
{...register("examplesText")}
/>
@@ -336,16 +344,16 @@ function SkillEditDialogBody({
</Field>
<Field>
<FieldLabel>工具白名单</FieldLabel>
<FieldLabel>{t("skillDefinition.toolWhitelist")}</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="没有可添加的工具"
placeholder={t("skillDefinition.selectTool")}
searchPlaceholder={t("skillDefinition.searchTool")}
emptyText={t("skillDefinition.emptyTool")}
onChange={handleAddToolWhitelist}
/>
</div>
@@ -355,13 +363,13 @@ function SkillEditDialogBody({
disabled={!toolCodeToAdd}
onClick={() => handleAddToolWhitelist(toolCodeToAdd)}
>
添加
{t("skillDefinition.add")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{selectedToolOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
不限制时,Skill 会继承 Agent 的可用工具范围。
{t("skillDefinition.inheritAgentTools")}
</span>
) : (
selectedToolOptions.map((option) => (
@@ -382,12 +390,12 @@ function SkillEditDialogBody({
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="skill-remark">备注</FieldLabel>
<FieldLabel htmlFor="skill-remark">{t("skillDefinition.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="skill-remark"
rows={3}
placeholder="记录内部备注或维护说明"
placeholder={t("skillDefinition.remarkPlaceholder")}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
+65 -44
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
BrainCircuitIcon,
BugIcon,
@@ -51,19 +51,35 @@ import {
type PageResult,
type SkillDefinition,
} from "@/lib/api/admin"
import { Status, StatusLabels } from "@/lib/generated/enums"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { useI18n } from "@/i18n/provider"
import { Status } from "@/lib/generated/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 TFunction = (key: string, values?: Record<string, string | number>) => string
function statusLabel(status: number, t: TFunction) {
if (status === Status.Ok) {
return t("skillDefinition.statusOk")
}
if (status === Status.Disabled) {
return t("skillDefinition.statusDisabled")
}
if (status === Status.Deleted) {
return t("skillDefinition.statusDeleted")
}
return String(status)
}
function getStatusFilterOptions(t: TFunction) {
return [
{ value: "all", label: t("skillDefinition.allStatus") },
{ value: String(Status.Ok), label: t("skillDefinition.statusOk") },
{ value: String(Status.Disabled), label: t("skillDefinition.statusDisabled") },
{ value: String(Status.Deleted), label: t("skillDefinition.statusDeleted") },
]
}
type SkillRowProps = {
item: SkillDefinition
@@ -73,6 +89,7 @@ type SkillRowProps = {
handleToggleStatus: (item: SkillDefinition) => void
handleDelete: (item: SkillDefinition) => void
handleRestore: (item: SkillDefinition) => void
t: TFunction
}
function SkillRow({
@@ -83,6 +100,7 @@ function SkillRow({
handleToggleStatus,
handleDelete,
handleRestore,
t,
}: SkillRowProps) {
const isDeleted = item.status === Status.Deleted
const statusBadgeVariant = isDeleted
@@ -102,12 +120,12 @@ function SkillRow({
<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>
<Badge variant="secondary">{t("skillDefinition.whitelistCount", { count: item.toolWhitelist.length })}</Badge>
<Badge variant="secondary">{t("skillDefinition.exampleCount", { count: 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 || "暂无描述"}
{item.description || t("skillDefinition.noDescription")}
</div>
</div>
{item.toolWhitelist.length > 0 ? (
@@ -131,10 +149,10 @@ function SkillRow({
checked={item.status === Status.Ok}
disabled={actionLoadingId === item.id || isDeleted}
onCheckedChange={() => void handleToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("skillDefinition.toggleStatus", { name: item.name })}
/>
<Badge variant={statusBadgeVariant}>
{getEnumLabel(StatusLabels, item.status as keyof typeof StatusLabels)}
{statusLabel(item.status, t)}
</Badge>
</div>
</TableCell>
@@ -150,15 +168,15 @@ function SkillRow({
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openDebugDialog(item)}>
<BugIcon />
调试
{t("skillDefinition.debug")}
</Button>
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
编辑
{t("skillDefinition.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
aria-label={t("skillDefinition.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -169,7 +187,7 @@ function SkillRow({
onClick={() => void handleRestore(item)}
>
<RotateCcwIcon />
{actionLoadingId === item.id ? "恢复中..." : "恢复"}
{actionLoadingId === item.id ? t("skillDefinition.restoring") : t("skillDefinition.restore")}
</DropdownMenuItem>
) : (
<DropdownMenuItem
@@ -178,7 +196,7 @@ function SkillRow({
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("skillDefinition.deleting") : t("skillDefinition.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
@@ -190,6 +208,7 @@ function SkillRow({
}
export default function DashboardSkillsPage() {
const t = useI18n()
const [nameInput, setNameInput] = useState("")
const [codeInput, setCodeInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
@@ -209,6 +228,7 @@ export default function DashboardSkillsPage() {
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const statusFilterOptions = useMemo(() => getStatusFilterOptions(t), [t])
const loadData = useCallback(async () => {
setLoading(true)
@@ -222,11 +242,11 @@ export default function DashboardSkillsPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Skills 失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.loadFailed"))
} finally {
setLoading(false)
}
}, [name, code, statusFilter, page, limit])
}, [name, code, statusFilter, page, limit, t])
useEffect(() => {
void loadData()
@@ -298,16 +318,16 @@ export default function DashboardSkillsPage() {
id: editingItem.id,
...payload,
})
toast.success(`已更新 Skill:${editingItem.name}`)
toast.success(t("skillDefinition.updated", { name: editingItem.name }))
} else {
await createSkillDefinition(payload)
toast.success(`已创建 Skill:${payload.name}`)
toast.success(t("skillDefinition.created", { name: payload.name }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存 Skill 失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.saveFailed"))
} finally {
setSaving(false)
}
@@ -323,10 +343,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id)
try {
await updateSkillDefinitionStatus(item.id, nextStatus)
toast.success(`已${nextStatus === Status.Ok ? "启用" : "停用"}:${item.name}`)
toast.success(t(nextStatus === Status.Ok ? "skillDefinition.enabled" : "skillDefinition.disabled", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -340,10 +360,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id)
try {
await deleteSkillDefinition(item.id)
toast.success(`已删除 Skill:${item.name}`)
toast.success(t("skillDefinition.deleted", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除 Skill 失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -357,10 +377,10 @@ export default function DashboardSkillsPage() {
setActionLoadingId(item.id)
try {
await restoreSkillDefinition(item.id)
toast.success(`已恢复 Skill:${item.name}`)
toast.success(t("skillDefinition.restored", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复 Skill 失败")
toast.error(error instanceof Error ? error.message : t("skillDefinition.restoreFailed"))
} finally {
setActionLoadingId(null)
}
@@ -374,11 +394,11 @@ export default function DashboardSkillsPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
刷新
{t("skillDefinition.refresh")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
新建
{t("skillDefinition.new")}
</Button>
</>
}
@@ -389,7 +409,7 @@ export default function DashboardSkillsPage() {
value={nameInput}
onChange={(event) => setNameInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选"
placeholder={t("skillDefinition.filterName")}
className="pl-9"
/>
</div>
@@ -397,22 +417,22 @@ export default function DashboardSkillsPage() {
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按编码筛选"
placeholder={t("skillDefinition.filterCode")}
className="w-full sm:w-56"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
options={statusFilterOptions}
placeholder="全部状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
placeholder={t("skillDefinition.allStatus")}
searchPlaceholder={t("skillDefinition.searchStatus")}
emptyText={t("skillDefinition.emptyStatus")}
onChange={setStatusFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("skillDefinition.query")}
</Button>
</DashboardToolbar>
@@ -435,9 +455,9 @@ export default function DashboardSkillsPage() {
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Skill</TableHead>
<TableHead>状态</TableHead>
<TableHead>最近更新</TableHead>
<TableHead className="w-[168px] text-right">操作</TableHead>
<TableHead>{t("skillDefinition.status")}</TableHead>
<TableHead>{t("skillDefinition.updatedAt")}</TableHead>
<TableHead className="w-[168px] text-right">{t("skillDefinition.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -445,8 +465,8 @@ export default function DashboardSkillsPage() {
<DashboardTableStateRow
colSpan={4}
loading={loading}
loadingText="正在加载 Skill..."
emptyText="没有匹配的 Skill"
loadingText={t("skillDefinition.loadingRows")}
emptyText={t("skillDefinition.emptyRows")}
/>
) : null}
{result.results.map((item) => (
@@ -459,6 +479,7 @@ export default function DashboardSkillsPage() {
handleToggleStatus={handleToggleStatus}
handleDelete={handleDelete}
handleRestore={handleRestore}
t={t}
/>
))}
</TableBody>
+52 -40
View File
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
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 {
@@ -22,6 +23,7 @@ import {
type Tag,
type TagTree,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type TagFormDialogProps = {
open: boolean;
@@ -37,18 +39,11 @@ const emptyForm: EditForm = {
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>
>;
type EditForm = {
parentId: string;
name: string;
remark: string;
};
function buildForm(item: Tag | null): EditForm {
if (!item) {
@@ -142,16 +137,38 @@ function TagFormDialogBody({
onOpenChange,
onSubmit,
}: TagFormDialogBodyProps) {
const t = useI18n();
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>
>({
const tagFormSchema = useMemo(
() =>
z.object({
parentId: z.string(),
name: z.string().trim().min(1, t("tag.nameRequired")),
remark: z.string(),
}),
[t],
);
const editFormResolver = useMemo(
() => zodResolver(tagFormSchema as never) as Resolver<EditForm>,
[tagFormSchema],
);
const parentOptions = useMemo(
() => [
{ value: "0", label: t("tag.rootParent") },
...parentTags.map((tag) => ({
value: String(tag.id),
label: `${" ".repeat(tag.depth)}${tag.name}`,
})),
],
[parentTags, t],
);
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
});
@@ -205,7 +222,7 @@ function TagFormDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑标签" : "新建标签"}
title={itemId ? t("tag.editTitle") : t("tag.createTitle")}
size="md"
allowFullscreen
footer={
@@ -216,17 +233,17 @@ function TagFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("tag.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("tag.saving") : itemId ? t("tag.save") : t("tag.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("tag.loadingDetail")}</div>
</div>
) : (
<form
@@ -235,37 +252,32 @@ function TagFormDialogBody({
className="space-y-4"
>
<Field data-invalid={!!errors.parentId}>
<FieldLabel htmlFor="tag-parent-id">父标签</FieldLabel>
<FieldLabel htmlFor="tag-parent-id">{t("tag.parent")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="parentId"
render={({ field }) => (
<select
id="tag-parent-id"
<OptionCombobox
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>
options={parentOptions}
placeholder={t("tag.rootParent")}
searchPlaceholder={t("tag.searchParent")}
emptyText={t("tag.emptyParent")}
disabled={saving}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.parentId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="tag-name">标签名称</FieldLabel>
<FieldLabel htmlFor="tag-name">{t("tag.name")}</FieldLabel>
<FieldContent>
<Input
id="tag-name"
placeholder="请输入标签名称"
placeholder={t("tag.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
@@ -273,11 +285,11 @@ function TagFormDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="tag-remark">备注</FieldLabel>
<FieldLabel htmlFor="tag-remark">{t("tag.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="tag-remark"
placeholder="请输入备注(可选)"
placeholder={t("tag.remarkPlaceholder")}
rows={3}
aria-invalid={!!errors.remark}
{...register("remark")}
+53 -48
View File
@@ -50,6 +50,7 @@ import {
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page"
import { OptionCombobox } from "@/components/option-combobox"
import { EditDialog } from "./_components/edit"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -71,6 +72,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { useI18n } from "@/i18n/provider"
type TagNode = TagTree & {
children: TagNode[]
@@ -151,12 +153,6 @@ function filterTree(nodes: TagNode[], keyword: string, status?: number): TagNode
return result
}
const listStatusOptions = [
{ value: "all", label: "全部状态" },
{ value: "0", label: "启用" },
{ value: "1", label: "禁用" },
] as const
type SortableRowProps = {
item: TagNode & { hasChildren: boolean }
disabled: boolean
@@ -178,6 +174,7 @@ function SortableRow({
onDelete,
actionLoadingId,
}: SortableRowProps) {
const t = useI18n()
const {
attributes,
listeners,
@@ -211,7 +208,7 @@ function SortableRow({
size="icon"
className="size-8 cursor-grab active:cursor-grabbing"
disabled={disabled}
aria-label={`拖拽排序 ${item.name}`}
aria-label={t("tag.dragSort", { name: item.name })}
{...attributes}
{...listeners}
>
@@ -250,10 +247,10 @@ function SortableRow({
checked={item.status === 0}
disabled={actionLoadingId === item.id}
onCheckedChange={() => void onToggleStatus(item)}
aria-label={`${item.name} 状态切换`}
aria-label={t("tag.toggleStatus", { name: item.name })}
/>
<Badge variant={item.status === 0 ? "default" : "outline"}>
{item.status === 0 ? "启用" : "禁用"}
{item.status === 0 ? t("status.ok") : t("status.disabled")}
</Badge>
</div>
</TableCell>
@@ -272,12 +269,12 @@ function SortableRow({
size="sm"
onClick={() => onEdit(item)}
>
编辑
{t("tag.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.name}`}
aria-label={t("tag.moreActions", { name: item.name })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -287,7 +284,7 @@ function SortableRow({
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("tag.deleting") : t("tag.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -298,6 +295,7 @@ function SortableRow({
}
export default function DashboardTagsPage() {
const t = useI18n()
const [keywordInput, setKeywordInput] = useState("")
const [statusFilterInput, setStatusFilterInput] = useState("all")
const [keyword, setKeyword] = useState("")
@@ -324,6 +322,15 @@ export default function DashboardTagsPage() {
})
)
const listStatusOptions = useMemo(
() => [
{ value: "all", label: t("status.all") },
{ value: "0", label: t("status.ok") },
{ value: "1", label: t("status.disabled") },
],
[t]
)
const loadData = useCallback(async () => {
setLoading(true)
try {
@@ -336,11 +343,11 @@ export default function DashboardTagsPage() {
setAllTags(Array.isArray(listData.results) ? listData.results : [])
setExpandedIds(collectParentIds(nextTree))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载标签失败")
toast.error(error instanceof Error ? error.message : t("tag.loadFailed"))
} finally {
setLoading(false)
}
}, [])
}, [t])
useEffect(() => {
void loadData()
@@ -415,16 +422,16 @@ export default function DashboardTagsPage() {
id: editingItem.id,
...payload,
})
toast.success(`已更新标签:${editingItem.name}`)
toast.success(t("tag.updated", { name: editingItem.name }))
} else {
await createTag(payload)
toast.success(`已创建标签:${payload.name}`)
toast.success(t("tag.created", { name: payload.name }))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存标签失败")
toast.error(error instanceof Error ? error.message : t("tag.saveFailed"))
} finally {
setSaving(false)
}
@@ -435,10 +442,10 @@ export default function DashboardTagsPage() {
try {
const nextStatus = item.status === 0 ? 1 : 0
await updateTagStatus(item.id, nextStatus)
toast.success(`已${nextStatus === 0 ? "启用" : "禁用"}:${item.name}`)
toast.success(t(nextStatus === 0 ? "tag.enabled" : "tag.disabled", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("tag.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -448,10 +455,10 @@ export default function DashboardTagsPage() {
setActionLoadingId(item.id)
try {
await deleteTag(item.id)
toast.success(`已删除标签:${item.name}`)
toast.success(t("tag.deleted", { name: item.name }))
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除标签失败")
toast.error(error instanceof Error ? error.message : t("tag.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -498,7 +505,7 @@ export default function DashboardTagsPage() {
}
if (activeItem.parentId !== overItem.parentId) {
toast.error("只能在同一父标签下拖拽排序")
toast.error(t("tag.sameParentOnly"))
return
}
@@ -521,15 +528,15 @@ export default function DashboardTagsPage() {
const movedIndex = siblingIds.indexOf(movedId)
const targetIndex = siblingIds.indexOf(targetId)
if (movedIndex < 0 || targetIndex < 0) {
throw new Error("找不到标签")
throw new Error(t("tag.notFound"))
}
const newSiblingIds = arrayMove(siblingIds, movedIndex, targetIndex)
await updateTagSort(newSiblingIds)
toast.success("标签排序已更新")
toast.success(t("tag.sortUpdated"))
await loadData()
} catch (error) {
setFlatList(previousList)
toast.error(error instanceof Error ? error.message : "更新标签排序失败")
toast.error(error instanceof Error ? error.message : t("tag.sortUpdateFailed"))
} finally {
setSorting(false)
}
@@ -543,17 +550,17 @@ export default function DashboardTagsPage() {
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
刷新
{t("tag.refresh")}
</Button>
<Button variant="outline" onClick={expandAll} disabled={loading}>
展开全部
{t("tag.expandAll")}
</Button>
<Button variant="outline" onClick={collapseAll} disabled={loading}>
折叠全部
{t("tag.collapseAll")}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
新建标签
{t("tag.new")}
</Button>
</>
}
@@ -564,24 +571,22 @@ export default function DashboardTagsPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按名称筛选"
placeholder={t("tag.filterName")}
className="pl-9"
/>
</div>
<select
<OptionCombobox
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>
options={listStatusOptions}
placeholder={t("status.all")}
searchPlaceholder={t("tag.searchStatus")}
emptyText={t("tag.emptyStatus")}
disabled={loading}
onChange={handleStatusFilterChange}
/>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
查询
{t("tag.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell>
@@ -594,11 +599,11 @@ export default function DashboardTagsPage() {
<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>
<TableHead className="min-w-[260px]">{t("tag.columnName")}</TableHead>
<TableHead>{t("tag.columnStatus")}</TableHead>
<TableHead>{t("tag.columnRemark")}</TableHead>
<TableHead>{t("tag.columnCreatedAt")}</TableHead>
<TableHead className="w-[92px] text-right">{t("tag.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -624,8 +629,8 @@ export default function DashboardTagsPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载标签..."
emptyText="没有匹配的标签"
loadingText={t("tag.loading")}
emptyText={t("tag.empty")}
/>
) : null}
</TableBody>
@@ -2,6 +2,7 @@
import { toast } from "sonner"
import { useI18n } from "@/i18n/provider"
import { createTicketFromConversation } from "@/lib/api/ticket"
import { EditDialog } from "./edit"
@@ -26,6 +27,7 @@ export function CreateTicketFromConversationDialog({
onOpenChange,
onSuccess,
}: CreateTicketFromConversationDialogProps) {
const t = useI18n()
const initialValues = conversation
? {
title: conversation.customerName || "",
@@ -43,11 +45,11 @@ export function CreateTicketFromConversationDialog({
fixedConversationId={conversation?.id}
fixedCustomerId={conversation?.customerId}
initialValues={initialValues}
titleOverride="会话转工单"
descriptionOverride="从当前会话上下文创建正式工单"
titleOverride={t("ticket.conversationToTicket")}
descriptionOverride={t("ticket.conversationToTicketDescription")}
onSubmit={async (payload) => {
if (!conversation?.id) {
throw new Error("会话不存在")
throw new Error(t("ticket.conversationMissing"))
}
await createTicketFromConversation({
conversationId: conversation.id,
@@ -56,7 +58,7 @@ export function CreateTicketFromConversationDialog({
currentAssigneeId: payload.currentAssigneeId,
tagIds: payload.tagIds,
})
toast.success("工单创建成功")
toast.success(t("ticket.createSuccess"))
onSuccess?.()
}}
/>
+43 -35
View File
@@ -42,6 +42,9 @@ import {
type TicketItem,
type UpdateTicketPayload,
} from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
type TFunction = (key: string, values?: Record<string, string | number>) => string
type EditDialogProps = {
open: boolean
@@ -56,20 +59,21 @@ type EditDialogProps = {
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
}
const schema = z.object({
title: z.string().trim().min(1, "请输入工单标题"),
description: z.string().refine((value) => !isRichTextEmpty(value), "请输入问题描述"),
function createSchema(t: TFunction) {
return z.object({
title: z.string().trim().min(1, t("ticket.titleRequired")),
description: z.string().refine((value) => !isRichTextEmpty(value), t("ticket.descriptionRequired")),
currentAssigneeId: z.coerce.number().int().min(0).optional(),
tagIds: z.array(z.number().int().positive()).default([]),
})
})
}
type EditForm = z.infer<typeof schema>
const editFormResolver = zodResolver(schema as never) as Resolver<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>
type EditForm = {
title: string
description: string
currentAssigneeId?: number
tagIds: number[]
}
const emptyForm: EditForm = {
title: "",
@@ -130,9 +134,10 @@ type TicketTagSelectorProps = {
value?: number[]
onChange: (value: number[]) => void
availableTags: TagTree[]
t: TFunction
}
function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelectorProps) {
function TicketTagSelector({ value, onChange, availableTags, t }: TicketTagSelectorProps) {
const selectedValues = useMemo(() => value ?? [], [value])
const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues])
@@ -158,14 +163,14 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
}
>
<TagIcon className="size-4" />
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"}
{selectedTags.length > 0 ? t("ticket.selectedTags", { count: selectedTags.length }) : t("ticket.selectTags")}
</PopoverTrigger>
<PopoverContent align="start" className="w-[320px] p-0">
<Command>
<CommandInput placeholder="搜索标签" />
<CommandInput placeholder={t("ticket.searchTags")} />
<CommandList>
<CommandEmpty>暂无可用标签</CommandEmpty>
<CommandGroup heading="标签">
<CommandEmpty>{t("ticket.emptyTags")}</CommandEmpty>
<CommandGroup heading={t("ticket.tags")}>
{flatTags.map((tag) => {
const checked = selectedTagIDs.has(tag.id)
return (
@@ -245,15 +250,17 @@ function TicketEditDialogBody({
onOpenChange,
onSubmit,
}: TicketEditDialogBodyProps) {
const t = useI18n()
const formId = "ticket-edit-form"
const [loading, setLoading] = useState(false)
const [tags, setTags] = useState<TagTree[]>([])
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
const schema = useMemo(() => createSchema(t), [t])
const editFormResolver = useMemo(
() => zodResolver(schema) as Resolver<EditForm>,
[schema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -296,14 +303,14 @@ function TicketEditDialogBody({
})()
}, [open])
const agentOptions = [{ value: "0", label: "不指定处理人" }].concat(
const agentOptions = [{ value: "0", label: t("ticket.noAssignee") }].concat(
agents.map((agent) => ({
value: String(agent.userId),
label:
agent.displayName ||
agent.nickname ||
agent.username ||
`客服#${agent.userId}`,
t("ticket.agentFallback", { id: agent.userId }),
})),
)
@@ -328,8 +335,8 @@ function TicketEditDialogBody({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={titleOverride || (itemId ? "编辑工单" : "新建工单")}
description={descriptionOverride || "填写工单基础信息"}
title={titleOverride || (itemId ? t("ticket.editTitle") : t("ticket.createTitle"))}
description={descriptionOverride || t("ticket.dialogDescription")}
size="lg"
allowFullscreen
footer={
@@ -340,27 +347,27 @@ function TicketEditDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("ticket.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("ticket.saving") : itemId ? t("ticket.save") : t("ticket.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("ticket.loading")}</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<FieldGroup>
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="ticket-title">标题</FieldLabel>
<FieldLabel htmlFor="ticket-title">{t("ticket.title")}</FieldLabel>
<FieldContent>
<Input
id="ticket-title"
placeholder="请输入工单标题"
placeholder={t("ticket.titlePlaceholder")}
aria-invalid={!!errors.title}
{...register("title")}
/>
@@ -369,7 +376,7 @@ function TicketEditDialogBody({
</Field>
<Field data-invalid={!!errors.description}>
<FieldLabel>描述</FieldLabel>
<FieldLabel>{t("ticket.description")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -378,7 +385,7 @@ function TicketEditDialogBody({
<ContentEditor
value={{ mode: "html", raw: field.value ?? "" }}
onChange={(next) => field.onChange(next.raw)}
placeholder="请输入问题描述"
placeholder={t("ticket.descriptionRequired")}
disabled={saving || loading}
allowedModes={["html"]}
height={260}
@@ -390,7 +397,7 @@ function TicketEditDialogBody({
</Field>
<Field>
<FieldLabel>处理人</FieldLabel>
<FieldLabel>{t("ticket.assignee")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -399,7 +406,7 @@ function TicketEditDialogBody({
<OptionCombobox
value={String(field.value ?? 0)}
onChange={(value) => field.onChange(Number(value))}
placeholder="请选择处理人"
placeholder={t("ticket.selectAssignee")}
options={agentOptions}
/>
)}
@@ -408,7 +415,7 @@ function TicketEditDialogBody({
</Field>
<Field>
<FieldLabel>工单标签</FieldLabel>
<FieldLabel>{t("ticket.ticketTags")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -418,6 +425,7 @@ function TicketEditDialogBody({
value={field.value}
onChange={field.onChange}
availableTags={tags}
t={t}
/>
)}
/>
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form"
import { Controller, type Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
@@ -21,20 +21,22 @@ import {
fetchAgentProfilesAll,
type AdminAgentProfile,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { assignTicket } from "@/lib/api/ticket"
const schema = z.object({
toUserId: z.string().trim().min(1, "请选择处理人"),
type TFunction = (key: string, values?: Record<string, string | number>) => string
function createSchema(t: TFunction) {
return z.object({
toUserId: z.string().trim().min(1, t("ticket.assigneeRequired")),
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 FormValues = {
toUserId: string
reason: string
}
const emptyForm: FormValues = {
toUserId: "",
@@ -77,16 +79,18 @@ function TicketAssignDialogBody({
onOpenChange,
onSuccess,
}: Omit<TicketAssignDialogProps, "open">) {
const t = useI18n()
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const activeRef = useRef(false)
const form = useForm<
z.input<typeof schema>,
undefined,
z.output<typeof schema>
>({
const schema = useMemo(() => createSchema(t), [t])
const resolver = useMemo(
() => zodResolver(schema) as Resolver<FormValues>,
[schema],
)
const form = useForm<FormValues>({
resolver,
defaultValues: emptyForm,
})
@@ -126,18 +130,18 @@ function TicketAssignDialogBody({
if (!activeRef.current) {
return
}
toast.error(error instanceof Error ? error.message : "加载处理人失败")
toast.error(error instanceof Error ? error.message : t("ticket.loadAssigneesFailed"))
})
.finally(() => {
if (activeRef.current) {
setLoading(false)
}
})
}, [])
}, [t])
async function onFormSubmit(values: FormValues) {
if (!ticketId) {
toast.error("请选择工单")
toast.error(t("ticket.selectTicket"))
return
}
setSaving(true)
@@ -150,7 +154,7 @@ function TicketAssignDialogBody({
if (!activeRef.current) {
return
}
toast.success("处理人已更新")
toast.success(t("ticket.assigneeUpdated"))
if (!activeRef.current) {
return
}
@@ -160,7 +164,7 @@ function TicketAssignDialogBody({
if (!activeRef.current) {
return
}
toast.error(error instanceof Error ? error.message : "指派工单失败")
toast.error(error instanceof Error ? error.message : t("ticket.assignFailed"))
} finally {
if (activeRef.current) {
setSaving(false)
@@ -171,12 +175,12 @@ function TicketAssignDialogBody({
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>指派工单</DialogTitle>
<DialogTitle>{t("ticket.assignTitle")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.toUserId}>
<FieldLabel>处理人</FieldLabel>
<FieldLabel>{t("ticket.assignee")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -185,14 +189,14 @@ function TicketAssignDialogBody({
<OptionCombobox
value={field.value}
onChange={field.onChange}
placeholder={loading ? "加载中..." : "选择处理人"}
placeholder={loading ? t("ticket.loading") : t("ticket.selectHandler")}
options={agents.map((agent) => ({
value: String(agent.userId),
label:
agent.displayName ||
agent.nickname ||
agent.username ||
`客服#${agent.userId}`,
t("ticket.agentFallback", { id: agent.userId }),
}))}
/>
)}
@@ -201,19 +205,19 @@ function TicketAssignDialogBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.reason}>
<FieldLabel>说明</FieldLabel>
<FieldLabel>{t("ticket.assignReason")}</FieldLabel>
<FieldContent>
<Textarea rows={4} placeholder="填写指派说明" {...register("reason")} />
<Textarea rows={4} placeholder={t("ticket.assignReasonPlaceholder")} {...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)}>
取消
{t("ticket.cancel")}
</Button>
<Button type="submit" disabled={saving}>
{saving ? "提交中..." : "确认指派"}
{saving ? t("ticket.submitting") : t("ticket.confirmAssign")}
</Button>
</DialogFooter>
</form>
@@ -31,6 +31,7 @@ import {
type UpdateTicketPayload,
updateTicket,
} from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
import { cn, formatDateTime } from "@/lib/utils"
import { EditDialog } from "./edit"
import { TicketAssignDialog } from "./ticket-assign-dialog"
@@ -43,18 +44,22 @@ type TicketDetailDialogProps = {
onChanged: () => void
}
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: "pending", label: "待处理" },
{ value: "in_progress", label: "处理中" },
{ value: "done", label: "已处理" },
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
function sourceLabel(source: string) {
function getStatusOptions(t: TFunction): Array<{ value: TicketStatus; label: string }> {
return [
{ value: "pending", label: t("ticket.statusPending") },
{ value: "in_progress", label: t("ticket.statusInProgress") },
{ value: "done", label: t("ticket.statusDone") },
]
}
function sourceLabel(source: string, t: TFunction) {
switch (source) {
case "manual":
return "手动创建"
return t("ticket.manualCreated")
case "conversation":
return "会话生成"
return t("ticket.conversationGenerated")
default:
return source || "-"
}
@@ -77,6 +82,7 @@ export function TicketDetailDialog({
onOpenChange,
onChanged,
}: TicketDetailDialogProps) {
const t = useI18n()
const [detail, setDetail] = useState<TicketDetail | null>(null)
const [loading, setLoading] = useState(false)
const [statusSaving, setStatusSaving] = useState<TicketStatus | null>(null)
@@ -120,13 +126,13 @@ export function TicketDetailDialog({
if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "加载工单详情失败")
toast.error(error instanceof Error ? error.message : t("ticket.loadDetailFailed"))
} finally {
if (loadSeqRef.current === seq) {
setLoading(false)
}
}
}, [open, ticketId])
}, [open, t, ticketId])
useEffect(() => {
dialogSeqRef.current += 1
@@ -160,7 +166,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.success("工单状态已更新")
toast.success(t("ticket.statusUpdated"))
await loadDetail(activeTicketId, activeDialogSeq)
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
@@ -170,7 +176,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "更新工单状态失败")
toast.error(error instanceof Error ? error.message : t("ticket.statusUpdateFailed"))
} finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setStatusSaving(null)
@@ -186,7 +192,7 @@ export function TicketDetailDialog({
const activeDialogSeq = dialogSeqRef.current
const content = progressContent.trim()
if (isRichTextEmpty(content)) {
toast.error("请填写处理进展")
toast.error(t("ticket.progressRequired"))
return
}
setProgressSaving(true)
@@ -198,7 +204,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.success("处理进展已记录")
toast.success(t("ticket.progressRecorded"))
setProgressContent("")
setProgressOpen(false)
await loadDetail(activeTicketId, activeDialogSeq)
@@ -210,7 +216,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "记录处理进展失败")
toast.error(error instanceof Error ? error.message : t("ticket.progressCreateFailed"))
} finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setProgressSaving(false)
@@ -233,7 +239,7 @@ export function TicketDetailDialog({
async function handleUpdateTicket(payload: CreateTicketPayload | UpdateTicketPayload) {
if (!("ticketId" in payload) || payload.ticketId <= 0) {
toast.error("请选择工单")
toast.error(t("ticket.selectTicket"))
return
}
const activeDialogSeq = dialogSeqRef.current
@@ -243,7 +249,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
return
}
toast.success("工单已更新")
toast.success(t("ticket.updated"))
setEditOpen(false)
await loadDetail(payload.ticketId, activeDialogSeq)
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
@@ -254,7 +260,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "更新工单失败")
toast.error(error instanceof Error ? error.message : t("ticket.updateFailed"))
} finally {
if (isCurrentOperation(payload.ticketId, activeDialogSeq)) {
setEditSaving(false)
@@ -265,7 +271,7 @@ export function TicketDetailDialog({
async function handleUpdateCustomer(payload: CustomerFormSavePayload) {
const activeCustomerId = getTicketCustomerId(ticket)
if (!ticket?.id || activeCustomerId <= 0) {
toast.error("当前工单未关联客户")
toast.error(t("ticket.noLinkedCustomer"))
return
}
if (customerEditSaving) {
@@ -279,7 +285,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.success("已保存")
toast.success(t("ticket.saved"))
setCustomerEditOpen(false)
await loadDetail(activeTicketId, activeDialogSeq)
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
@@ -290,7 +296,7 @@ export function TicketDetailDialog({
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
return
}
toast.error(error instanceof Error ? error.message : "保存失败")
toast.error(error instanceof Error ? error.message : t("ticket.saveFailed"))
} finally {
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
setCustomerEditSaving(false)
@@ -313,6 +319,7 @@ export function TicketDetailDialog({
const ticket = detail?.ticket
const customerId = getTicketCustomerId(ticket)
const statusOptions = getStatusOptions(t)
return (
<>
@@ -321,7 +328,7 @@ export function TicketDetailDialog({
onOpenChange={onOpenChange}
title={
<div className="flex min-w-0 items-center gap-2 pr-16 text-base">
<span className="truncate">{ticket?.title ?? "工单详情"}</span>
<span className="truncate">{ticket?.title ?? t("ticket.detailTitle")}</span>
{ticket ? <TicketStatusBadge status={ticket.status} /> : null}
</div>
}
@@ -329,8 +336,8 @@ export function TicketDetailDialog({
ticket ? (
<span className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-mono">{ticket.ticketNo}</span>
<span>{sourceLabel(ticket.source)}</span>
<span>创建人:{metadataValue(ticket.createdByName || ticket.createdBy)}</span>
<span>{sourceLabel(ticket.source, t)}</span>
<span>{t("ticket.creator", { name: metadataValue(ticket.createdByName || ticket.createdBy) })}</span>
</span>
) : undefined
}
@@ -342,21 +349,21 @@ export function TicketDetailDialog({
{loading && !ticket ? (
<div className="flex h-130 items-center justify-center gap-2 text-sm text-muted-foreground">
<RefreshCcwIcon className="size-4 animate-spin" />
加载中...
{t("ticket.loading")}
</div>
) : ticket ? (
<div className="grid w-full h-full grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_380px] border-t">
<div className="min-h-0 space-y-5 overflow-y-auto border-b p-6 lg:border-r lg:border-b-0">
<section className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">描述</div>
<div className="text-sm font-medium text-muted-foreground">{t("ticket.description")}</div>
<div className="rounded-md border bg-muted/30 px-3 py-2">
<SafeRichHTML html={ticket.description} fallback="暂无描述" />
<SafeRichHTML html={ticket.description} fallback={t("ticket.noDescription")} />
</div>
</section>
<section className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">状态</div>
<div className="text-sm font-medium text-muted-foreground">{t("ticket.columnStatus")}</div>
<div className="flex flex-wrap gap-2">
{statusOptions.map((option) => (
<Button
@@ -367,20 +374,20 @@ export function TicketDetailDialog({
disabled={!!statusSaving}
onClick={() => void handleStatusChange(option.value)}
>
{statusSaving === option.value ? "更新中..." : option.label}
{statusSaving === option.value ? t("ticket.updating") : option.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">负责人</div>
<div className="text-sm font-medium text-muted-foreground">{t("ticket.columnAssignee")}</div>
<div className="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-sm">
<UserRoundIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{ticket.currentAssigneeName || "未分配"}</span>
<span className="truncate">{ticket.currentAssigneeName || t("ticket.unassigned")}</span>
</div>
<Button type="button" size="sm" variant="outline" onClick={() => setAssignOpen(true)}>
指派
{t("ticket.assign")}
</Button>
</div>
</div>
@@ -388,9 +395,9 @@ export function TicketDetailDialog({
<section className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-muted-foreground">标签</div>
<div className="text-sm font-medium text-muted-foreground">{t("ticket.tags")}</div>
<Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
编辑
{t("ticket.edit")}
</Button>
</div>
{ticket.tags && ticket.tags.length > 0 ? (
@@ -402,13 +409,13 @@ export function TicketDetailDialog({
))}
</div>
) : (
<div className="text-sm text-muted-foreground">暂无标签</div>
<div className="text-sm text-muted-foreground">{t("ticket.emptyTags")}</div>
)}
</section>
<section className="space-y-3 rounded-md border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-muted-foreground">客户信息</div>
<div className="font-medium text-muted-foreground">{t("ticket.customerInfo")}</div>
<Button
type="button"
variant="ghost"
@@ -423,22 +430,22 @@ export function TicketDetailDialog({
}}
>
<PencilIcon className="size-3.5" />
{customerId > 0 ? "编辑" : "关联或创建"}
{customerId > 0 ? t("ticket.edit") : t("ticket.linkOrCreate")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<MetadataItem label="客户" value={ticket.customer?.name || ticket.customerId} />
<MetadataItem label="联系方式" value={ticket.customer?.primaryMobile || ticket.customer?.primaryEmail} />
<MetadataItem label={t("ticket.customer")} value={ticket.customer?.name || ticket.customerId} />
<MetadataItem label={t("ticket.contact")} value={ticket.customer?.primaryMobile || ticket.customer?.primaryEmail} />
</div>
</section>
<section className="space-y-3 rounded-md border p-3 text-sm">
<div className="font-medium text-muted-foreground">工单信息</div>
<div className="font-medium text-muted-foreground">{t("ticket.ticketInfo")}</div>
<div className="grid gap-3 sm:grid-cols-2">
<MetadataItem label="来源" value={sourceLabel(ticket.source)} />
<MetadataItem label="渠道" value={ticket.channel} />
<MetadataItem label="会话 ID" value={ticket.conversationId || undefined} />
<MetadataItem label="最后更新" value={ticket.updatedAt ? formatDateTime(ticket.updatedAt) : undefined} />
<MetadataItem label={t("ticket.source")} value={sourceLabel(ticket.source, t)} />
<MetadataItem label={t("ticket.channel")} value={ticket.channel} />
<MetadataItem label={t("ticket.conversationId")} value={ticket.conversationId || undefined} />
<MetadataItem label={t("ticket.columnUpdated")} value={ticket.updatedAt ? formatDateTime(ticket.updatedAt) : undefined} />
</div>
</section>
</div>
@@ -447,11 +454,11 @@ export function TicketDetailDialog({
<div className="flex items-center justify-between gap-2 px-4 py-2">
<div className="flex items-center gap-2 text-sm font-medium">
<MessageSquareTextIcon className="size-4 text-muted-foreground" />
处理进展
{t("ticket.progress")}
</div>
<Button type="button" size="sm" onClick={() => setProgressOpen(true)}>
<PlusIcon className="size-3.5" />
添加进展
{t("ticket.addProgress")}
</Button>
</div>
<Separator />
@@ -471,7 +478,7 @@ export function TicketDetailDialog({
</div>
<div className="min-w-0 flex-1 pb-3">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{progress.authorName || `用户#${progress.authorId}`}</span>
<span>{progress.authorName || t("ticket.userFallback", { id: progress.authorId })}</span>
<span>{progress.createdAt ? formatDateTime(progress.createdAt) : "-"}</span>
</div>
<SafeRichHTML html={progress.content} className="mt-1" />
@@ -481,14 +488,14 @@ export function TicketDetailDialog({
</div>
) : (
<div className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
暂无处理进展
{t("ticket.noProgress")}
</div>
)}
</div>
</aside>
</div>
) : (
<div className="flex h-[360px] items-center justify-center text-sm text-muted-foreground">请选择工单</div>
<div className="flex h-[360px] items-center justify-center text-sm text-muted-foreground">{t("ticket.chooseTicket")}</div>
)}
</ProjectDialog>
@@ -533,13 +540,13 @@ export function TicketDetailDialog({
>
<DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle>添加处理进展</DialogTitle>
<DialogTitle>{t("ticket.addProgress")}</DialogTitle>
</DialogHeader>
<div className="px-6 py-4">
<ContentEditor
value={{ mode: "html", raw: progressContent }}
onChange={(next) => setProgressContent(next.raw)}
placeholder="记录本次处理进展"
placeholder={t("ticket.progressPlaceholder")}
disabled={progressSaving}
allowedModes={["html"]}
height={260}
@@ -555,11 +562,11 @@ export function TicketDetailDialog({
setProgressContent("")
}}
>
取消
{t("ticket.cancel")}
</Button>
<Button type="button" disabled={progressSaving} onClick={() => void handleCreateProgress()}>
<SendIcon className="size-3.5" />
{progressSaving ? "提交中..." : "提交"}
{progressSaving ? t("ticket.submitting") : t("ticket.submit")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,24 +1,26 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { useI18n } from "@/i18n/provider"
import type { TicketStatus } from "@/lib/api/ticket"
const statusMap = {
pending: { label: "待处理", className: "border-amber-200 bg-amber-50 text-amber-700" },
in_progress: { label: "处理中", className: "border-blue-200 bg-blue-50 text-blue-700" },
done: { label: "已处理", className: "border-emerald-200 bg-emerald-50 text-emerald-700" },
pending: { labelKey: "ticket.statusPending", className: "border-amber-200 bg-amber-50 text-amber-700" },
in_progress: { labelKey: "ticket.statusInProgress", className: "border-blue-200 bg-blue-50 text-blue-700" },
done: { labelKey: "ticket.statusDone", className: "border-emerald-200 bg-emerald-50 text-emerald-700" },
} as const
export function ticketStatusLabel(status: string) {
return statusMap[status as TicketStatus]?.label ?? status
return status
}
export function TicketStatusBadge({ status }: { status: string }) {
const t = useI18n()
const option = statusMap[status as TicketStatus]
return (
<Badge variant="outline" className={option?.className ?? "border-border bg-muted text-muted-foreground"}>
{option?.label ?? status}
{option ? t(option.labelKey) : status}
</Badge>
)
}
+65 -50
View File
@@ -39,6 +39,7 @@ import {
type TicketStatus,
type TicketSummary,
} from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
import { cn, formatDateTime } from "@/lib/utils"
import { EditDialog } from "./_components/edit"
import { TicketDetailDialog } from "./_components/ticket-detail-dialog"
@@ -63,13 +64,23 @@ const emptySummary: TicketSummary = {
stale: 0,
}
const assigneeAllOption: ComboboxOption = { value: "0", label: "全部负责人" }
const tagAllOption: ComboboxOption = { value: "0", label: "全部标签" }
const staleHourOptions: ComboboxOption[] = [
{ value: "24", label: "24 小时" },
{ value: "48", label: "48 小时" },
{ value: "168", label: "168 小时" },
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
function getAssigneeAllOption(t: TFunction): ComboboxOption {
return { value: "0", label: t("ticket.allAssignees") }
}
function getTagAllOption(t: TFunction): ComboboxOption {
return { value: "0", label: t("ticket.allTags") }
}
function getStaleHourOptions(t: TFunction): ComboboxOption[] {
return [
{ value: "24", label: t("ticket.hours", { hours: 24 }) },
{ value: "48", label: t("ticket.hours", { hours: 48 }) },
{ value: "168", label: t("ticket.hours", { hours: 168 }) },
]
}
function buildTagOptions(nodes: TagTree[], parentPath = ""): ComboboxOption[] {
const result: ComboboxOption[] = []
@@ -86,28 +97,29 @@ function buildTagOptions(nodes: TagTree[], parentPath = ""): ComboboxOption[] {
return result
}
function sourceLabel(source: string) {
function sourceLabel(source: string, t: TFunction) {
switch (source) {
case "manual":
return "手动"
return t("ticket.manual")
case "conversation":
return "会话"
return t("ticket.conversation")
default:
return source || "-"
}
}
function assigneeLabel(ticket: TicketItem) {
function assigneeLabel(ticket: TicketItem, t: TFunction) {
if (ticket.currentAssigneeName) {
return ticket.currentAssigneeName
}
if (ticket.currentAssigneeId > 0) {
return `客服#${ticket.currentAssigneeId}`
return t("ticket.agentFallback", { id: ticket.currentAssigneeId })
}
return "未分配"
return t("ticket.unassigned")
}
export default function TicketsPage() {
const t = useI18n()
const searchParams = useSearchParams()
const [tickets, setTickets] = useState<TicketItem[]>([])
const [summary, setSummary] = useState<TicketSummary>(emptySummary)
@@ -116,8 +128,8 @@ export default function TicketsPage() {
const [assigneeId, setAssigneeId] = useState("0")
const [tagId, setTagId] = useState("0")
const [staleHours, setStaleHours] = useState("24")
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([assigneeAllOption])
const [tagOptions, setTagOptions] = useState<ComboboxOption[]>([tagAllOption])
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([])
const [tagOptions, setTagOptions] = useState<ComboboxOption[]>([])
const [loading, setLoading] = useState(false)
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
@@ -128,16 +140,19 @@ export default function TicketsPage() {
const quickViews = useMemo(
() =>
[
{ key: "all", label: "全部工单", count: summary.all },
{ key: "pending", label: "待处理", count: summary.pending },
{ key: "in_progress", label: "处理中", count: summary.inProgress },
{ key: "done", label: "已处理", count: summary.done },
{ key: "unassigned", label: "待分配", count: summary.unassigned },
{ key: "mine", label: "我的工单", count: summary.mine },
{ key: "stale", label: "长时间未更新", count: summary.stale },
{ key: "all", label: t("ticket.quickAll"), count: summary.all },
{ key: "pending", label: t("ticket.quickPending"), count: summary.pending },
{ key: "in_progress", label: t("ticket.quickInProgress"), count: summary.inProgress },
{ key: "done", label: t("ticket.quickDone"), count: summary.done },
{ key: "unassigned", label: t("ticket.quickUnassigned"), count: summary.unassigned },
{ key: "mine", label: t("ticket.quickMine"), count: summary.mine },
{ key: "stale", label: t("ticket.quickStale"), count: summary.stale },
] satisfies Array<{ key: QuickViewKey; label: string; count: number }>,
[summary],
[summary, t],
)
const assigneeAllOption = useMemo(() => getAssigneeAllOption(t), [t])
const tagAllOption = useMemo(() => getTagAllOption(t), [t])
const staleHourOptions = useMemo(() => getStaleHourOptions(t), [t])
const loadData = useCallback(async () => {
const seq = loadSeqRef.current + 1
@@ -179,13 +194,13 @@ export default function TicketsPage() {
if (loadSeqRef.current !== seq) {
return
}
toast.error(error instanceof Error ? error.message : "加载工单失败")
toast.error(error instanceof Error ? error.message : t("ticket.loadFailed"))
} finally {
if (loadSeqRef.current === seq) {
setLoading(false)
}
}
}, [assigneeId, keyword, quickView, staleHours, tagId])
}, [assigneeId, keyword, quickView, staleHours, tagId, t])
useEffect(() => {
void loadData()
@@ -214,18 +229,18 @@ export default function TicketsPage() {
agent.displayName ||
agent.nickname ||
agent.username ||
`客服#${agent.userId}`,
t("ticket.agentFallback", { id: agent.userId }),
})),
])
setTagOptions([tagAllOption, ...buildTagOptions(Array.isArray(tags) ? tags : [])])
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "加载筛选项失败")
toast.error(error instanceof Error ? error.message : t("ticket.loadFiltersFailed"))
})
return () => {
active = false
}
}, [])
}, [assigneeAllOption, tagAllOption, t])
function resetFilters() {
setQuickView("all")
@@ -239,11 +254,11 @@ export default function TicketsPage() {
setSavingCreate(true)
try {
await createTicket(payload)
toast.success("工单已创建")
toast.success(t("ticket.created"))
setCreateOpen(false)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建工单失败")
toast.error(error instanceof Error ? error.message : t("ticket.createFailed"))
} finally {
setSavingCreate(false)
}
@@ -282,18 +297,18 @@ export default function TicketsPage() {
<>
<Button type="button" variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCcwIcon className={cn("size-4", loading ? "animate-spin" : "")} />
刷新
{t("ticket.refresh")}
</Button>
<Button type="button" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
新建工单
{t("ticket.newTicket")}
</Button>
</>
}
>
<Input
className="w-full sm:w-72"
placeholder="搜索编号、标题或描述"
placeholder={t("ticket.searchPlaceholder")}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onKeyDown={(event) => {
@@ -306,7 +321,7 @@ export default function TicketsPage() {
<OptionCombobox
value={assigneeId}
onChange={setAssigneeId}
placeholder="全部负责人"
placeholder={t("ticket.allAssignees")}
options={assigneeOptions}
/>
</div>
@@ -314,7 +329,7 @@ export default function TicketsPage() {
<OptionCombobox
value={tagId}
onChange={setTagId}
placeholder="全部标签"
placeholder={t("ticket.allTags")}
options={tagOptions}
/>
</div>
@@ -322,21 +337,21 @@ export default function TicketsPage() {
<OptionCombobox
value={staleHours}
onChange={setStaleHours}
placeholder="未更新阈值"
placeholder={t("ticket.staleThreshold")}
options={staleHourOptions}
/>
</div>
<Button type="button" variant="outline" onClick={resetFilters}>
<SearchXIcon className="size-4" />
重置
{t("ticket.reset")}
</Button>
<Button type="button" variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCcwIcon className={cn("size-4", loading ? "animate-spin" : "")} />
刷新
{t("ticket.refresh")}
</Button>
<Button type="button" onClick={() => void loadData()} disabled={loading}>
<SearchIcon className="size-4" />
查询
{t("ticket.query")}
</Button>
</DashboardToolbar>
@@ -344,11 +359,11 @@ export default function TicketsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>工单</TableHead>
<TableHead className="w-28">状态</TableHead>
<TableHead className="w-36">负责人</TableHead>
<TableHead className="w-40">最后更新</TableHead>
<TableHead className="w-24 text-right">操作</TableHead>
<TableHead>{t("ticket.columnTicket")}</TableHead>
<TableHead className="w-28">{t("ticket.columnStatus")}</TableHead>
<TableHead className="w-36">{t("ticket.columnAssignee")}</TableHead>
<TableHead className="w-40">{t("ticket.columnUpdated")}</TableHead>
<TableHead className="w-24 text-right">{t("ticket.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -356,8 +371,8 @@ export default function TicketsPage() {
<DashboardTableStateRow
colSpan={5}
loading={loading}
loadingText="正在加载工单..."
emptyText="暂无工单"
loadingText={t("ticket.loadingRows")}
emptyText={t("ticket.emptyRows")}
/>
) : (
tickets.map((ticket) => (
@@ -367,8 +382,8 @@ export default function TicketsPage() {
<div className="truncate text-sm font-medium">{ticket.title}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{ticket.ticketNo}</span>
<span>{ticket.customer?.name || (ticket.customerId ? `客户#${ticket.customerId}` : "无客户")}</span>
<span>{sourceLabel(ticket.source)}</span>
<span>{ticket.customer?.name || (ticket.customerId ? t("ticket.customerFallback", { id: ticket.customerId }) : t("ticket.noCustomer"))}</span>
<span>{sourceLabel(ticket.source, t)}</span>
{ticket.channel ? <span>{ticket.channel}</span> : null}
</div>
{ticket.tags && ticket.tags.length > 0 ? (
@@ -391,7 +406,7 @@ export default function TicketsPage() {
<TicketStatusBadge status={ticket.status} />
</TableCell>
<TableCell className="max-w-36 truncate text-sm text-muted-foreground">
{assigneeLabel(ticket)}
{assigneeLabel(ticket, t)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "-"}
@@ -406,7 +421,7 @@ export default function TicketsPage() {
setDetailOpen(true)
}}
>
详情
{t("ticket.detail")}
</Button>
</TableCell>
</TableRow>
@@ -25,6 +25,8 @@ import {
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { getRoleDisplayName } from "@/lib/role-i18n"
import { cn } from "@/lib/utils"
type AssignRolesDrawerProps = {
@@ -103,6 +105,8 @@ function AssignRolesDrawerBody({
onOpenChange,
onSubmit,
}: AssignRolesDrawerBodyProps) {
const t = useI18n()
const { locale } = useAppLocale()
const [keyword, setKeyword] = useState("")
const form = useForm<
z.input<typeof assignRolesSchema>,
@@ -135,7 +139,7 @@ function AssignRolesDrawerBody({
return (
<DrawerContent className="flex min-w-2xl flex-col overflow-hidden">
<DrawerHeader>
<DrawerTitle>分配角色</DrawerTitle>
<DrawerTitle>{t("user.assignRoles")}</DrawerTitle>
</DrawerHeader>
<form
className="flex min-h-0 flex-1 flex-col overflow-hidden"
@@ -161,13 +165,15 @@ function AssignRolesDrawerBody({
if (!output) {
return true
}
return `${role.name} ${role.code}`.toLowerCase().includes(output)
return `${role.name} ${getRoleDisplayName(role.code, role.name, locale)} ${role.code}`
.toLowerCase()
.includes(output)
})
return (
<div className="space-y-4 px-4 pb-4">
<Field>
<FieldLabel>当前已分配</FieldLabel>
<FieldLabel>{t("user.assignedRoles")}</FieldLabel>
<FieldContent>
<div className="rounded-lg border p-3">
{selectedRoles.length > 0 ? (
@@ -183,27 +189,29 @@ function AssignRolesDrawerBody({
) : (
<ShieldAlertIcon className="size-3" />
)}
{role.name}
{role.status !== Status.Ok ? "(已禁用)" : ""}
{getRoleDisplayName(role.code, role.name, locale)}
{role.status !== Status.Ok ? ` (${t("user.disabled")})` : ""}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">当前未分配角色</div>
<div className="text-sm text-muted-foreground">
{t("user.unassignedRoles")}
</div>
)}
</div>
</FieldContent>
</Field>
<Field data-invalid={!!errors.roleIds}>
<FieldLabel>角色列表</FieldLabel>
<FieldLabel>{t("user.roleList")}</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="搜索角色名称或编码"
placeholder={t("user.searchRoleNameOrCode")}
className="pl-9"
disabled={loading}
/>
@@ -211,7 +219,7 @@ function AssignRolesDrawerBody({
<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">
正在加载角色列表...
{t("user.loadingRoleList")}
</div>
) : filteredRoles.length > 0 ? (
filteredRoles.map((role) => {
@@ -244,7 +252,9 @@ function AssignRolesDrawerBody({
/>
<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 font-medium">
{getRoleDisplayName(role.code, role.name, locale)}
</span>
<span className="truncate text-muted-foreground">
{role.code}
</span>
@@ -252,21 +262,21 @@ function AssignRolesDrawerBody({
</div>
{role.isSystem ? (
<Badge variant="outline" className="shrink-0">
系统
{t("user.system")}
</Badge>
) : null}
<Badge
variant={role.status === Status.Ok ? "secondary" : "outline"}
className="shrink-0"
>
{role.status === Status.Ok ? "启用" : "禁用"}
{role.status === Status.Ok ? t("user.enabled") : t("user.disabled")}
</Badge>
</label>
)
})
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
没有匹配的角色
{t("user.noMatchedRoles")}
</div>
)}
</div>
@@ -275,37 +285,37 @@ function AssignRolesDrawerBody({
</Field>
<Field>
<FieldLabel>本次变更</FieldLabel>
<FieldLabel>{t("user.changes")}</FieldLabel>
<FieldContent>
<div className="space-y-3 rounded-lg border p-3">
<div>
<div className="mb-2 text-sm font-medium">新增角色</div>
<div className="mb-2 text-sm font-medium">{t("user.addedRoles")}</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}
{getRoleDisplayName(role.code, role.name, locale)}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">无新增</div>
<div className="text-sm text-muted-foreground">{t("user.noneAdded")}</div>
)}
</div>
<div>
<div className="mb-2 text-sm font-medium">移除角色</div>
<div className="mb-2 text-sm font-medium">{t("user.removedRoles")}</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}
{getRoleDisplayName(role.code, role.name, locale)}
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">无移除</div>
<div className="text-sm text-muted-foreground">{t("user.noneRemoved")}</div>
)}
</div>
</div>
@@ -318,7 +328,7 @@ function AssignRolesDrawerBody({
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || loading || !item}>
{saving ? "保存中..." : "确认分配"}
{saving ? t("user.saving") : t("user.confirmAssign")}
</Button>
<Button
type="button"
@@ -326,7 +336,7 @@ function AssignRolesDrawerBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("user.cancel")}
</Button>
</DrawerFooter>
</form>
+78 -65
View File
@@ -11,7 +11,9 @@ import {
type AdminRole,
type CreateAdminUserPayload,
} from "@/lib/api/admin"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { Status } from "@/lib/generated/enums"
import { getRoleDisplayName } from "@/lib/role-i18n"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -39,36 +41,15 @@ type CreateUserDrawerProps = {
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>
type CreateForm = {
username: string
nickname: string
avatar: string
mobile: string
email: string
remark: string
roleIds: number[]
}
const emptyForm: CreateForm = {
username: "",
@@ -80,12 +61,6 @@ const emptyForm: CreateForm = {
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
@@ -134,14 +109,48 @@ function CreateUserDrawerBody({
onOpenChange,
onSubmit,
}: CreateUserDrawerBodyProps) {
const t = useI18n()
const { locale } = useAppLocale()
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>
>({
const createFormSchema = useMemo(
() =>
z.object({
username: z.string().trim().min(1, t("user.usernameRequired")),
nickname: z.string().trim(),
avatar: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^https?:\/\/\S+$/i.test(value),
t("user.avatarInvalid")
),
mobile: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^[0-9+\-\s]{6,20}$/.test(value),
t("user.mobileInvalid")
),
email: z
.string()
.trim()
.refine(
(value) =>
value.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
t("user.emailInvalid")
),
remark: z.string().trim(),
roleIds: z.array(z.number().int().positive()),
}),
[t]
)
const createFormResolver = useMemo(
() => zodResolver(createFormSchema as never) as Resolver<CreateForm>,
[createFormSchema]
)
const form = useForm<CreateForm>({
resolver: createFormResolver,
defaultValues: emptyForm,
})
@@ -174,9 +183,11 @@ function CreateUserDrawerBody({
return roles
}
return roles.filter((role) =>
`${role.name} ${role.code}`.toLowerCase().includes(q)
`${role.name} ${getRoleDisplayName(role.code, role.name, locale)} ${role.code}`
.toLowerCase()
.includes(q)
)
}, [roleKeyword, roles])
}, [locale, roleKeyword, roles])
async function onFormSubmit(values: CreateForm) {
await onSubmit(buildPayload(values))
@@ -187,9 +198,9 @@ function CreateUserDrawerBody({
return (
<DrawerContent className="min-w-2xl">
<DrawerHeader>
<DrawerTitle>添加用户</DrawerTitle>
<DrawerTitle>{t("user.createTitle")}</DrawerTitle>
<DrawerDescription>
提交后由系统生成初始密码,并仅展示一次,请妥善保存。
{t("user.createDescription")}
</DrawerDescription>
</DrawerHeader>
<form
@@ -198,11 +209,11 @@ function CreateUserDrawerBody({
>
<div className="space-y-4 overflow-y-auto px-4 pb-4">
<Field data-invalid={!!errors.username}>
<FieldLabel htmlFor="create-username">用户名</FieldLabel>
<FieldLabel htmlFor="create-username">{t("user.username")}</FieldLabel>
<FieldContent>
<Input
id="create-username"
placeholder="登录名,必填"
placeholder={t("user.usernamePlaceholder")}
autoComplete="off"
aria-invalid={!!errors.username}
{...register("username")}
@@ -211,11 +222,11 @@ function CreateUserDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.nickname}>
<FieldLabel htmlFor="create-nickname">昵称</FieldLabel>
<FieldLabel htmlFor="create-nickname">{t("user.nickname")}</FieldLabel>
<FieldContent>
<Input
id="create-nickname"
placeholder="可选,默认同用户名"
placeholder={t("user.nicknameCreatePlaceholder")}
aria-invalid={!!errors.nickname}
{...register("nickname")}
/>
@@ -223,11 +234,11 @@ function CreateUserDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.avatar}>
<FieldLabel htmlFor="create-avatar">头像地址</FieldLabel>
<FieldLabel htmlFor="create-avatar">{t("user.avatar")}</FieldLabel>
<FieldContent>
<Input
id="create-avatar"
placeholder="可选,http(s) 链接"
placeholder={t("user.avatarCreatePlaceholder")}
aria-invalid={!!errors.avatar}
{...register("avatar")}
/>
@@ -235,11 +246,11 @@ function CreateUserDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.mobile}>
<FieldLabel htmlFor="create-mobile">手机号</FieldLabel>
<FieldLabel htmlFor="create-mobile">{t("user.mobile")}</FieldLabel>
<FieldContent>
<Input
id="create-mobile"
placeholder="可选"
placeholder={t("user.optional")}
aria-invalid={!!errors.mobile}
{...register("mobile")}
/>
@@ -247,11 +258,11 @@ function CreateUserDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="create-email">邮箱</FieldLabel>
<FieldLabel htmlFor="create-email">{t("user.email")}</FieldLabel>
<FieldContent>
<Input
id="create-email"
placeholder="可选"
placeholder={t("user.optional")}
aria-invalid={!!errors.email}
{...register("email")}
/>
@@ -259,11 +270,11 @@ function CreateUserDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="create-remark">备注</FieldLabel>
<FieldLabel htmlFor="create-remark">{t("user.remark")}</FieldLabel>
<FieldContent>
<Input
id="create-remark"
placeholder="可选"
placeholder={t("user.optional")}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
@@ -272,14 +283,14 @@ function CreateUserDrawerBody({
</Field>
<Field data-invalid={!!errors.roleIds}>
<FieldLabel>角色(可选)</FieldLabel>
<FieldLabel>{t("user.rolesOptional")}</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="搜索角色"
placeholder={t("user.searchRoles")}
className="pl-9"
disabled={rolesLoading}
/>
@@ -294,7 +305,7 @@ function CreateUserDrawerBody({
<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">
正在加载角色...
{t("user.loadingRoles")}
</div>
) : filteredRoles.length > 0 ? (
filteredRoles.map((role) => {
@@ -332,10 +343,12 @@ function CreateUserDrawerBody({
) : (
<ShieldAlertIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{role.name}</span>
<span className="truncate">
{getRoleDisplayName(role.code, role.name, locale)}
</span>
{role.status !== Status.Ok ? (
<Badge variant="outline" className="text-xs">
已禁用
{t("user.disabledBadge")}
</Badge>
) : null}
</span>
@@ -344,7 +357,7 @@ function CreateUserDrawerBody({
})
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
暂无角色
{t("user.emptyRoles")}
</div>
)}
</div>
@@ -357,7 +370,7 @@ function CreateUserDrawerBody({
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || rolesLoading}>
{saving ? "创建中..." : "创建用户"}
{saving ? t("user.creating") : t("user.createUser")}
</Button>
<Button
type="button"
@@ -365,7 +378,7 @@ function CreateUserDrawerBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("user.cancel")}
</Button>
</DrawerFooter>
</form>
+58 -51
View File
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
@@ -10,6 +10,7 @@ import {
type UpdateAdminUserPayload,
fetchUserDetail,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { Button } from "@/components/ui/button"
import {
Drawer,
@@ -42,38 +43,12 @@ const emptyForm: EditForm = {
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>
>
type EditForm = {
nickname: string
avatar: string
mobile: string
email: string
}
function toNullableString(value: string) {
const output = value.trim()
@@ -139,13 +114,43 @@ function UserEditDrawerBody({
onOpenChange,
onSubmit,
}: UserEditDrawerBodyProps) {
const t = useI18n()
const [loading, setLoading] = useState(false)
const [item, setItem] = useState<AdminUser | null>(null)
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
const editFormSchema = useMemo(
() =>
z.object({
nickname: z.string().trim().min(1, t("user.nicknameRequired")),
avatar: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^https?:\/\/\S+$/i.test(value),
t("user.avatarInvalid")
),
mobile: z
.string()
.trim()
.refine(
(value) => value.length === 0 || /^[0-9+\-\s]{6,20}$/.test(value),
t("user.mobileInvalid")
),
email: z
.string()
.trim()
.refine(
(value) =>
value.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
t("user.emailInvalid")
),
}),
[t]
)
const editFormResolver = useMemo(
() => zodResolver(editFormSchema as never) as Resolver<EditForm>,
[editFormSchema]
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -188,12 +193,14 @@ function UserEditDrawerBody({
return (
<DrawerContent className="min-w-2xl">
<DrawerHeader>
<DrawerTitle>修改用户</DrawerTitle>
<DrawerDescription>当前用户:{item?.username || "-"}</DrawerDescription>
<DrawerTitle>{t("user.editTitle")}</DrawerTitle>
<DrawerDescription>
{t("user.currentUser", { username: item?.username || "-" })}
</DrawerDescription>
</DrawerHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("user.loading")}</div>
</div>
) : (
<form
@@ -202,11 +209,11 @@ function UserEditDrawerBody({
>
<div className="space-y-4 px-4 pb-4">
<Field data-invalid={!!errors.nickname}>
<FieldLabel htmlFor="user-nickname">昵称</FieldLabel>
<FieldLabel htmlFor="user-nickname">{t("user.nickname")}</FieldLabel>
<FieldContent>
<Input
id="user-nickname"
placeholder="请输入昵称"
placeholder={t("user.nicknamePlaceholder")}
aria-invalid={!!errors.nickname}
{...register("nickname")}
/>
@@ -214,11 +221,11 @@ function UserEditDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.avatar}>
<FieldLabel htmlFor="user-avatar">头像地址</FieldLabel>
<FieldLabel htmlFor="user-avatar">{t("user.avatar")}</FieldLabel>
<FieldContent>
<Input
id="user-avatar"
placeholder="请输入头像 URL"
placeholder={t("user.avatarPlaceholder")}
aria-invalid={!!errors.avatar}
{...register("avatar")}
/>
@@ -226,11 +233,11 @@ function UserEditDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.mobile}>
<FieldLabel htmlFor="user-mobile">手机号</FieldLabel>
<FieldLabel htmlFor="user-mobile">{t("user.mobile")}</FieldLabel>
<FieldContent>
<Input
id="user-mobile"
placeholder="请输入手机号"
placeholder={t("user.mobilePlaceholder")}
aria-invalid={!!errors.mobile}
{...register("mobile")}
/>
@@ -238,11 +245,11 @@ function UserEditDrawerBody({
</FieldContent>
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="user-email">邮箱</FieldLabel>
<FieldLabel htmlFor="user-email">{t("user.email")}</FieldLabel>
<FieldContent>
<Input
id="user-email"
placeholder="请输入邮箱"
placeholder={t("user.emailPlaceholder")}
aria-invalid={!!errors.email}
{...register("email")}
/>
@@ -252,7 +259,7 @@ function UserEditDrawerBody({
</div>
<DrawerFooter className="border-t">
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存修改"}
{saving ? t("user.saving") : t("user.saveEdit")}
</Button>
<Button
type="button"
@@ -260,7 +267,7 @@ function UserEditDrawerBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("user.cancel")}
</Button>
</DrawerFooter>
</form>
@@ -5,6 +5,7 @@ import { CopyIcon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { useI18n } from "@/i18n/provider"
import {
Dialog,
DialogContent,
@@ -27,6 +28,7 @@ export function InitialPasswordDialog({
password,
onOpenChange,
}: InitialPasswordDialogProps) {
const t = useI18n()
const [copying, setCopying] = useState(false)
async function handleCopy() {
@@ -37,9 +39,9 @@ export function InitialPasswordDialog({
setCopying(true)
try {
await navigator.clipboard.writeText(password)
toast.success("密码已复制")
toast.success(t("user.copied"))
} catch {
toast.error("复制失败,请手动复制")
toast.error(t("user.copyFailed"))
} finally {
setCopying(false)
}
@@ -49,13 +51,13 @@ export function InitialPasswordDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>用户已创建</DialogTitle>
<DialogTitle>{t("user.createdTitle")}</DialogTitle>
<DialogDescription>
{username || "-"} 的初始密码已生成,仅在此展示一次,请及时复制并安全传达。
{t("user.initialPasswordDescription", { username: username || "-" })}
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border bg-muted/40 p-4">
<div className="text-xs text-muted-foreground">初始密码</div>
<div className="text-xs text-muted-foreground">{t("user.initialPassword")}</div>
<div className="mt-2 break-all font-mono text-base">{password}</div>
</div>
<DialogFooter>
@@ -66,10 +68,10 @@ export function InitialPasswordDialog({
disabled={copying || !password}
>
<CopyIcon />
{copying ? "复制中..." : "复制密码"}
{copying ? t("user.copying") : t("user.copyPassword")}
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
关闭
{t("user.close")}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,6 +5,7 @@ import { CopyIcon } from "lucide-react"
import { toast } from "sonner"
import { type AdminUser } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -32,6 +33,7 @@ export function ResetPasswordDialogs({
onOpenChange,
onConfirm,
}: ResetPasswordDialogsProps) {
const t = useI18n()
const [copying, setCopying] = useState(false)
const showingResult = password.trim().length > 0
@@ -43,9 +45,9 @@ export function ResetPasswordDialogs({
setCopying(true)
try {
await navigator.clipboard.writeText(password)
toast.success("密码已复制")
toast.success(t("user.copied"))
} catch {
toast.error("复制失败,请手动复制")
toast.error(t("user.copyFailed"))
} finally {
setCopying(false)
}
@@ -56,14 +58,14 @@ export function ResetPasswordDialogs({
<Dialog open={open && !showingResult} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={!saving}>
<DialogHeader>
<DialogTitle>确认重置密码</DialogTitle>
<DialogTitle>{t("user.confirmResetTitle")}</DialogTitle>
<DialogDescription>
确认后将为 {item?.username || "-"} 生成新的随机密码,并使该用户当前登录会话失效。
{t("user.confirmResetDescription", { username: item?.username || "-" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => void onConfirm()} disabled={saving}>
{saving ? "重置中..." : "确认重置"}
{saving ? t("user.resetting") : t("user.confirmReset")}
</Button>
<Button
type="button"
@@ -71,7 +73,7 @@ export function ResetPasswordDialogs({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("user.cancel")}
</Button>
</DialogFooter>
</DialogContent>
@@ -79,22 +81,22 @@ export function ResetPasswordDialogs({
<Dialog open={open && showingResult} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>重置密码成功</DialogTitle>
<DialogTitle>{t("user.resetSuccessTitle")}</DialogTitle>
<DialogDescription>
{item?.username || "-"} 的新密码已生成,请及时复制并安全传达。
{t("user.resetSuccessDescription", { username: item?.username || "-" })}
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border bg-muted/40 p-4">
<div className="text-xs text-muted-foreground">新密码</div>
<div className="text-xs text-muted-foreground">{t("user.newPassword")}</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 ? "复制中..." : "复制密码"}
{copying ? t("user.copying") : t("user.copyPassword")}
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
关闭
{t("user.close")}
</Button>
</DialogFooter>
</DialogContent>
+45 -36
View File
@@ -35,6 +35,8 @@ import {
type UpdateAdminUserPayload,
} from "@/lib/api/admin"
import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { getRoleDisplayName } from "@/lib/role-i18n"
import { formatDateTime } from "@/lib/utils"
import { AssignRolesDrawer } from "./_components/assign-roles"
import { CreateUserDrawer } from "./_components/create"
@@ -61,6 +63,8 @@ import {
} from "@/components/ui/table"
export default function DashboardUsersPage() {
const t = useI18n()
const { locale } = useAppLocale()
const [keywordInput, setKeywordInput] = useState("")
const [keyword, setKeyword] = useState("")
const [page, setPage] = useState(1)
@@ -99,11 +103,11 @@ export default function DashboardUsersPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载用户失败")
toast.error(error instanceof Error ? error.message : t("user.loadFailed"))
} finally {
setLoading(false)
}
}, [keyword, limit, page])
}, [keyword, limit, page, t])
useEffect(() => {
void loadUsers()
@@ -140,7 +144,7 @@ export default function DashboardUsersPage() {
setAssignRoleIds((userDetail.roles || []).map((role) => role.id))
} catch (error) {
setAssigningRolesUser(null)
toast.error(error instanceof Error ? error.message : "加载角色分配数据失败")
toast.error(error instanceof Error ? error.message : t("user.loadRoleAssignFailed"))
} finally {
setAssignRolesLoading(false)
setActionLoadingId(null)
@@ -188,7 +192,7 @@ export default function DashboardUsersPage() {
setSavingCreate(true)
try {
const result = await createUser(payload)
toast.success(`已创建用户 ${result.user.username}`)
toast.success(t("user.created", { username: result.user.username }))
setCreatingOpen(false)
setInitialPassword({
username: result.user.username,
@@ -196,7 +200,7 @@ export default function DashboardUsersPage() {
})
await loadUsers()
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建用户失败")
toast.error(error instanceof Error ? error.message : t("user.createFailed"))
} finally {
setSavingCreate(false)
}
@@ -221,11 +225,11 @@ export default function DashboardUsersPage() {
setSavingEdit(true)
try {
await updateUser(payload)
toast.success(`已更新 ${editingUser?.username || "用户"}`)
toast.success(t("user.updated", { username: editingUser?.username || t("user.fallbackUser") }))
setEditingUser(null)
await loadUsers()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新用户失败")
toast.error(error instanceof Error ? error.message : t("user.updateFailed"))
} finally {
setSavingEdit(false)
}
@@ -239,13 +243,13 @@ export default function DashboardUsersPage() {
setSavingRoles(true)
try {
await assignUserRoles(assigningRolesUser.id, roleIds)
toast.success(`已更新 ${assigningRolesUser.username} 的角色`)
toast.success(t("user.rolesUpdated", { username: assigningRolesUser.username }))
setAssigningRolesUser(null)
setAssignRoleOptions([])
setAssignRoleIds([])
await loadUsers()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存角色分配失败")
toast.error(error instanceof Error ? error.message : t("user.saveRolesFailed"))
} finally {
setSavingRoles(false)
}
@@ -275,9 +279,9 @@ export default function DashboardUsersPage() {
try {
const result = await resetUserPassword(resettingUser.id)
setResetPasswordResult(result)
toast.success(`已重置 ${resettingUser.username} 的密码`)
toast.success(t("user.passwordReset", { username: resettingUser.username }))
} catch (error) {
toast.error(error instanceof Error ? error.message : "重置密码失败")
toast.error(error instanceof Error ? error.message : t("user.resetPasswordFailed"))
} finally {
setSavingPassword(false)
}
@@ -288,10 +292,15 @@ export default function DashboardUsersPage() {
try {
const nextStatus = user.status === Status.Ok ? Status.Disabled : Status.Ok
await updateUserStatus(user.id, nextStatus)
toast.success(`${user.username} 已${nextStatus === Status.Ok ? "启用" : "禁用"}`)
toast.success(
t("user.statusUpdated", {
username: user.username,
status: nextStatus === Status.Ok ? t("user.enabled") : t("user.disabled"),
})
)
await loadUsers()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新状态失败")
toast.error(error instanceof Error ? error.message : t("user.statusUpdateFailed"))
} finally {
setActionLoadingId(null)
}
@@ -304,7 +313,7 @@ export default function DashboardUsersPage() {
actions={
<Button onClick={() => setCreatingOpen(true)} disabled={loading}>
<PlusIcon />
添加用户
{t("user.addUser")}
</Button>
}
>
@@ -314,12 +323,12 @@ export default function DashboardUsersPage() {
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按用户名筛选"
placeholder={t("user.filterUsername")}
className="pl-9"
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
查询
{t("user.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
@@ -337,12 +346,12 @@ export default function DashboardUsersPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>用户</TableHead>
<TableHead>角色</TableHead>
<TableHead>状态</TableHead>
<TableHead>最后登录</TableHead>
<TableHead>联系方式</TableHead>
<TableHead className="w-[92px] text-right">操作</TableHead>
<TableHead>{t("user.columnUser")}</TableHead>
<TableHead>{t("user.columnRoles")}</TableHead>
<TableHead>{t("user.columnStatus")}</TableHead>
<TableHead>{t("user.columnLastLogin")}</TableHead>
<TableHead>{t("user.columnContact")}</TableHead>
<TableHead className="w-[92px] text-right">{t("user.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -365,21 +374,21 @@ export default function DashboardUsersPage() {
item.roles?.map((role) => (
<Badge key={role.id} variant="outline">
<ShieldIcon className="size-3" />
{role.name}
{getRoleDisplayName(role.code, role.name, locale)}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">未分配</span>
<span className="text-sm text-muted-foreground">{t("user.unassigned")}</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
{item.status === Status.Ok ? "启用" : "禁用"}
{item.status === Status.Ok ? t("user.enabled") : t("user.disabled")}
</Badge>
{item.isSystem ? (
<Badge variant="outline" className="ml-2">
系统
{t("user.system")}
</Badge>
) : null}
</TableCell>
@@ -403,12 +412,12 @@ export default function DashboardUsersPage() {
onClick={() => openEditDrawer(item)}
disabled={actionLoadingId === item.id}
>
编辑
{t("user.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.username}`}
aria-label={t("user.moreActions", { username: item.username })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -419,15 +428,15 @@ export default function DashboardUsersPage() {
>
<ShieldIcon />
{actionLoadingId === item.id
? "处理中..."
: "分配角色"}
? t("user.processing")
: t("user.assignRoles")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openResetDrawer(item)}
disabled={actionLoadingId === item.id}
>
<KeyRoundIcon />
重置密码
{t("user.resetPassword")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleToggleStatus(item)}
@@ -435,10 +444,10 @@ export default function DashboardUsersPage() {
>
<ShieldIcon />
{actionLoadingId === item.id
? "处理中..."
? t("user.processing")
: item.status === Status.Ok
? "禁用"
: "启用"}
? t("user.disabled")
: t("user.enabled")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -450,8 +459,8 @@ export default function DashboardUsersPage() {
<DashboardTableStateRow
colSpan={6}
loading={loading}
loadingText="正在加载用户数据..."
emptyText="没有匹配的用户数据"
loadingText={t("user.loadingRows")}
emptyText={t("user.emptyRows")}
/>
) : null}
</TableBody>
+8 -2
View File
@@ -1,6 +1,12 @@
"use client"
import { CheckCircle2Icon } from "lucide-react"
import { useI18n } from "@/i18n/provider"
export default function Page() {
const t = useI18n()
return (
<main className="flex min-h-svh items-center justify-center bg-muted px-6 text-foreground">
<section className="grid max-w-sm justify-items-center gap-4 text-center">
@@ -8,9 +14,9 @@ export default function Page() {
<CheckCircle2Icon className="size-7" />
</div>
<div className="grid gap-2">
<h1 className="text-lg font-semibold text-foreground">会话已结束</h1>
<h1 className="text-lg font-semibold text-foreground">{t("kefu.closedTitle")}</h1>
<p className="text-sm leading-6 text-muted-foreground">
当前客服会话已关闭,感谢您的使用。
{t("kefu.closedDescription")}
</p>
</div>
</section>
+17 -14
View File
@@ -7,6 +7,7 @@ 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 { AppI18nProvider } from "@/i18n/provider"
import "@/app/globals.css"
import "md-editor-rt/lib/style.css"
@@ -32,8 +33,8 @@ try {
`
export const metadata: Metadata = {
title: "AI 客服后台管理系统",
description: "AI 客服后台管理系统",
title: "AI Customer Service Admin",
description: "AI Customer Service Admin",
}
export default function RootLayout({
@@ -47,18 +48,20 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<script dangerouslySetInnerHTML={{ __html: paletteScript }} />
<ThemeProvider>
<AuthProvider>
<ConfirmProvider>
<ImageLightboxProvider>
<TooltipProvider>
{children}
<Toaster position="top-center" richColors />
</TooltipProvider>
</ImageLightboxProvider>
</ConfirmProvider>
</AuthProvider>
</ThemeProvider>
<AppI18nProvider>
<ThemeProvider>
<AuthProvider>
<ConfirmProvider>
<ImageLightboxProvider>
<TooltipProvider>
{children}
<Toaster position="top-center" richColors />
</TooltipProvider>
</ImageLightboxProvider>
</ConfirmProvider>
</AuthProvider>
</ThemeProvider>
</AppI18nProvider>
</body>
</html>
)
+14 -5
View File
@@ -4,6 +4,7 @@ import type { ComponentProps } from "react"
import Link from "next/link"
import { useMemo } from "react"
import { useI18n } from "@/i18n/provider"
import {
filterDashboardNavForSession,
filterDashboardSecondaryNavForSession,
@@ -23,6 +24,7 @@ import {
} from "@/components/ui/sidebar"
export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
const t = useI18n()
const { session } = useAuth()
const navSections = useMemo(
() => filterDashboardNavForSession(session?.permissions, session?.roles),
@@ -33,8 +35,8 @@ export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
[session?.permissions, session?.roles]
)
const user = {
name: session?.user.nickname || session?.user.username || "未登录",
email: session?.user.username || "guest",
name: session?.user.nickname || session?.user.username || t("common.notSignedIn"),
email: session?.user.username || t("common.guest"),
avatar: session?.user.avatar || "",
}
@@ -49,19 +51,26 @@ export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
>
<img
src="/images/logo.svg"
alt="贝壳AGENT"
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
<span className="text-base font-semibold">贝壳AGENT</span>
<span className="text-base font-semibold">{t("app.brand")}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{navSections.map((section) => (
<NavMain key={section.title} title={section.title} items={section.items} />
<NavMain
key={section.titleKey}
title={t(section.titleKey)}
items={section.items.map((item) => ({
...item,
title: t(item.titleKey),
}))}
/>
))}
{secondaryNavItems.length > 0 ? (
<NavSecondary items={secondaryNavItems} className="mt-auto" />
+38 -29
View File
@@ -1,12 +1,13 @@
"use client";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { toast } from "sonner";
import { changeSelfPassword } from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import { Button } from "@/components/ui/button";
import {
Field,
@@ -17,25 +18,22 @@ import {
import { Input } from "@/components/ui/input";
import { ProjectDialog } from "@/components/project-dialog";
const changePasswordSchema = z
.object({
password: z.string().trim().min(1, "新密码不能为空"),
confirmPassword: z.string().trim().min(1, "确认密码不能为空"),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "两次输入的密码不一致",
});
function createChangePasswordSchema(t: (key: string) => string) {
return z
.object({
password: z.string().trim().min(1, t("account.passwordRequired")),
confirmPassword: z.string().trim().min(1, t("account.confirmPasswordRequired")),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: t("account.passwordMismatch"),
});
}
type ChangePasswordForm = z.infer<typeof changePasswordSchema>;
const changePasswordResolver = zodResolver(
changePasswordSchema as never,
) as Resolver<
z.input<typeof changePasswordSchema>,
undefined,
z.output<typeof changePasswordSchema>
>;
type ChangePasswordForm = {
password: string;
confirmPassword: string;
};
const emptyForm: ChangePasswordForm = {
password: "",
@@ -53,6 +51,17 @@ export function ChangePasswordDialog({
onOpenChange,
onSuccess,
}: ChangePasswordDialogProps) {
const t = useI18n();
const changePasswordSchema = useMemo(() => createChangePasswordSchema(t), [t]);
const changePasswordResolver = useMemo(
() =>
zodResolver(changePasswordSchema as never) as Resolver<
z.input<typeof changePasswordSchema>,
undefined,
z.output<typeof changePasswordSchema>
>,
[changePasswordSchema],
);
const form = useForm<
z.input<typeof changePasswordSchema>,
undefined,
@@ -77,11 +86,11 @@ export function ChangePasswordDialog({
async function onSubmit(values: ChangePasswordForm) {
try {
await changeSelfPassword(values.password.trim());
toast.success("密码已修改,请重新登录");
toast.success(t("account.passwordChanged"));
onOpenChange(false);
await onSuccess();
} catch (error) {
toast.error(error instanceof Error ? error.message : "修改密码失败");
toast.error(error instanceof Error ? error.message : t("account.changePasswordFailed"));
}
}
@@ -89,8 +98,8 @@ export function ChangePasswordDialog({
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title="修改密码"
description="修改当前登录账号的密码,提交后需要重新登录。"
title={t("account.changePassword")}
description={t("account.changePasswordDescription")}
size="sm"
allowFullscreen
footer={
@@ -101,10 +110,10 @@ export function ChangePasswordDialog({
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
取消
{t("account.cancel")}
</Button>
<Button type="submit" form="change-password-form" disabled={isSubmitting}>
{isSubmitting ? "提交中..." : "确认修改"}
{isSubmitting ? t("account.submitting") : t("account.confirmChange")}
</Button>
</>
}
@@ -112,12 +121,12 @@ export function ChangePasswordDialog({
<form id="change-password-form" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<Field data-invalid={!!errors.password}>
<FieldLabel htmlFor="change-password-password">新密码</FieldLabel>
<FieldLabel htmlFor="change-password-password">{t("account.newPassword")}</FieldLabel>
<FieldContent>
<Input
id="change-password-password"
type="password"
placeholder="请输入新密码"
placeholder={t("account.newPasswordPlaceholder")}
autoComplete="new-password"
aria-invalid={!!errors.password}
{...register("password")}
@@ -126,12 +135,12 @@ export function ChangePasswordDialog({
</FieldContent>
</Field>
<Field data-invalid={!!errors.confirmPassword}>
<FieldLabel htmlFor="change-password-confirm">确认密码</FieldLabel>
<FieldLabel htmlFor="change-password-confirm">{t("account.confirmPassword")}</FieldLabel>
<FieldContent>
<Input
id="change-password-confirm"
type="password"
placeholder="请再次输入新密码"
placeholder={t("account.confirmPasswordPlaceholder")}
autoComplete="new-password"
aria-invalid={!!errors.confirmPassword}
{...register("confirmPassword")}
+20 -19
View File
@@ -28,6 +28,7 @@ import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
import { useI18n } from "@/i18n/provider"
const chartData = [
{ date: "2026-03-01", activeSessions: 18, indexedDocs: 12 },
@@ -46,28 +47,28 @@ const chartData = [
{ date: "2026-03-14", activeSessions: 49, indexedDocs: 50 },
]
const chartConfig = {
activeSessions: {
label: "活跃会话",
color: "var(--primary)",
},
indexedDocs: {
label: "知识文档",
color: "var(--chart-2)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const t = useI18n()
const [timeRange, setTimeRange] = React.useState("14d")
const filteredData = chartData.slice(timeRange === "7d" ? -7 : -14)
const chartConfig = {
activeSessions: {
label: t("scaffold.activeSessions"),
color: "var(--primary)",
},
indexedDocs: {
label: t("scaffold.indexedDocs"),
color: "var(--chart-2)",
},
} satisfies ChartConfig
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>近期开发活跃度</CardTitle>
<CardTitle>{t("scaffold.recentActivity")}</CardTitle>
<CardDescription>
以演示数据展示会话增长与知识库准备进度
{t("scaffold.activityDescription")}
</CardDescription>
<CardAction>
<ToggleGroup
@@ -79,8 +80,8 @@ export function ChartAreaInteractive() {
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
>
<ToggleGroupItem value="14d">近 14 天</ToggleGroupItem>
<ToggleGroupItem value="7d">近 7 天</ToggleGroupItem>
<ToggleGroupItem value="14d">{t("scaffold.last14Days")}</ToggleGroupItem>
<ToggleGroupItem value="7d">{t("scaffold.last7Days")}</ToggleGroupItem>
</ToggleGroup>
<Select
value={timeRange}
@@ -93,16 +94,16 @@ export function ChartAreaInteractive() {
<SelectTrigger
className="flex w-32 @[767px]/card:hidden"
size="sm"
aria-label="选择时间范围"
aria-label={t("scaffold.selectTimeRange")}
>
<SelectValue placeholder="近 14 天" />
<SelectValue placeholder={t("scaffold.last14Days")} />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="14d" className="rounded-lg">
近 14 天
{t("scaffold.last14Days")}
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
近 7 天
{t("scaffold.last7Days")}
</SelectItem>
</SelectContent>
</Select>
+16 -11
View File
@@ -34,6 +34,7 @@ import {
type UploadedEditorImage,
} from "@/lib/im-editor-image"
import { generateUUID } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
export type UploadedMessageEditorImage = UploadedEditorImage & {
url: string
@@ -74,6 +75,7 @@ export function SharedMessageEditor({
onUploadImage,
onSendAttachment,
}: SharedMessageEditorProps) {
const t = useI18n()
const [localUploading, setLocalUploading] = useState(false)
const imageInputRef = useRef<HTMLInputElement | null>(null)
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
@@ -83,9 +85,12 @@ export function SharedMessageEditor({
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
const isCustomer = variant === "customer"
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
placeholderRef.current = t("conversation.editorPlaceholder")
useEffect(() => {
const objectUrls = objectUrlsRef.current
return () => {
@@ -118,7 +123,7 @@ export function SharedMessageEditor({
}),
MessageImageExtension,
Placeholder.configure({
placeholder: "输入消息,Enter 发送,Shift + Enter 换行",
placeholder: () => placeholderRef.current,
}),
],
content: "",
@@ -320,8 +325,8 @@ export function SharedMessageEditor({
imageInputRef.current?.click()
}}
disabled={disabled || isUploading}
aria-label={isUploading ? "图片上传中" : "发送图片"}
title={isUploading ? "图片上传中" : "发送图片"}
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
>
<ImageIcon className={isCustomer ? undefined : "size-4"} />
</Button>
@@ -336,8 +341,8 @@ export function SharedMessageEditor({
attachmentInputRef.current?.click()
}}
disabled={disabled || isUploading}
aria-label={isUploading ? "附件上传中" : "发送附件"}
title={isUploading ? "附件上传中" : "发送附件"}
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
>
<PaperclipIcon className={isCustomer ? undefined : "size-4"} />
</Button>
@@ -359,9 +364,9 @@ export function SharedMessageEditor({
</PopoverTrigger>
<PopoverContent className="w-[30rem] p-0" align="start">
<Command>
<CommandInput placeholder="搜索快捷回复" />
<CommandInput placeholder={t("conversation.searchQuickReplies")} />
<CommandList>
<CommandEmpty>暂无快捷回复</CommandEmpty>
<CommandEmpty>{t("conversation.emptyQuickReplies")}</CommandEmpty>
<CommandGroup>
{quickReplies.items.map((item) => (
<CommandItem
@@ -390,7 +395,7 @@ export function SharedMessageEditor({
</div>
<div className="flex items-center gap-2">
<p className={isCustomer ? "hidden text-[10px] text-muted-foreground sm:block" : "text-xs text-muted-foreground"}>
Enter 发送
{t("conversation.enterToSend")}
</p>
{isCustomer ? (
<Button
@@ -398,8 +403,8 @@ export function SharedMessageEditor({
size="icon"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
aria-label="发送"
title="发送"
aria-label={t("conversation.send")}
title={t("conversation.send")}
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
>
<SendHorizonalIcon />
@@ -412,7 +417,7 @@ export function SharedMessageEditor({
disabled={disabled || isUploading}
>
<SendIcon className="mr-1 size-4" />
{isUploading ? "上传中..." : "发送"}
{isUploading ? t("conversation.uploading") : t("conversation.send")}
</Button>
)}
</div>
+16 -13
View File
@@ -27,6 +27,7 @@ import {
type AdminCompany,
type CreateAdminCompanyPayload,
} from "@/lib/api/company"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
type CompanyPickerProps = {
@@ -40,8 +41,10 @@ export function CompanyPicker({
value,
onChange,
disabled = false,
placeholder = "请选择公司",
placeholder,
}: CompanyPickerProps) {
const t = useI18n()
const resolvedPlaceholder = placeholder ?? t("companyPicker.placeholder")
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState("")
const [loading, setLoading] = useState(false)
@@ -74,7 +77,7 @@ export function CompanyPicker({
} catch (error) {
if (!cancelled) {
setOptions([])
toast.error(error instanceof Error ? error.message : "加载公司列表失败")
toast.error(error instanceof Error ? error.message : t("companyPicker.loadFailed"))
}
} finally {
if (!cancelled) {
@@ -86,7 +89,7 @@ export function CompanyPicker({
return () => {
cancelled = true
}
}, [open, trimmedKeyword])
}, [open, trimmedKeyword, t])
useEffect(() => {
let cancelled = false
@@ -123,7 +126,7 @@ export function CompanyPicker({
}, [normalizedKeyword, options, trimmedKeyword])
const buttonLabel =
Number(value) > 0 ? selectedCompany?.name || `公司 #${value}` : placeholder
Number(value) > 0 ? selectedCompany?.name || t("companyPicker.fallback", { id: value }) : resolvedPlaceholder
function handleSelectCompany(company: AdminCompany) {
setSelectedCompany(company)
@@ -148,9 +151,9 @@ export function CompanyPicker({
setCreateOpen(false)
setOpen(false)
setKeyword("")
toast.success(`已创建公司:${created.name}`)
toast.success(t("companyPicker.created", { name: created.name }))
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建公司失败")
toast.error(error instanceof Error ? error.message : t("companyPicker.createFailed"))
throw error
} finally {
setCreateSaving(false)
@@ -180,19 +183,19 @@ export function CompanyPicker({
<CommandInput
value={keyword}
onValueChange={setKeyword}
placeholder="搜索公司名称"
placeholder={t("companyPicker.searchPlaceholder")}
/>
<CommandList>
{loading ? <CommandEmpty>加载中...</CommandEmpty> : null}
{!loading && options.length === 0 ? <CommandEmpty>未找到匹配公司</CommandEmpty> : null}
{loading ? <CommandEmpty>{t("companyPicker.loading")}</CommandEmpty> : null}
{!loading && options.length === 0 ? <CommandEmpty>{t("companyPicker.empty")}</CommandEmpty> : null}
{!loading ? (
<CommandGroup heading="搜索结果">
<CommandGroup heading={t("companyPicker.results")}>
<CommandItem
value="none"
data-checked={Number(value) <= 0}
onSelect={handleClear}
>
<span>不关联公司</span>
<span>{t("companyPicker.none")}</span>
</CommandItem>
{options.map((item) => (
<CommandItem
@@ -214,13 +217,13 @@ export function CompanyPicker({
{canCreate ? (
<>
<CommandSeparator />
<CommandGroup heading="操作">
<CommandGroup heading={t("companyPicker.actions")}>
<CommandItem
value={`create ${trimmedKeyword}`}
onSelect={() => setCreateOpen(true)}
>
<PlusIcon className="size-4" />
<span className="truncate">新建公司“{trimmedKeyword}”</span>
<span className="truncate">{t("companyPicker.create", { name: trimmedKeyword })}</span>
</CommandItem>
</CommandGroup>
</>
+7 -9
View File
@@ -18,6 +18,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useI18n } from "@/i18n/provider"
type ConfirmOptions = {
title?: ReactNode
@@ -39,14 +40,11 @@ const ConfirmContext = createContext<ConfirmContextValue | null>(null)
const defaultState: ConfirmState = {
open: false,
title: "请确认操作",
description: "确认后将继续执行当前操作。",
confirmText: "确认",
cancelText: "取消",
variant: "default",
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const t = useI18n()
const [state, setState] = useState<ConfirmState>(defaultState)
const resolverRef = useRef<((value: boolean) => void) | null>(null)
@@ -63,17 +61,17 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
setState({
open: true,
title: options.title ?? defaultState.title,
description: options.description ?? defaultState.description,
confirmText: options.confirmText ?? defaultState.confirmText,
cancelText: options.cancelText ?? defaultState.cancelText,
title: options.title ?? t("confirm.title"),
description: options.description ?? t("confirm.description"),
confirmText: options.confirmText ?? t("confirm.confirm"),
cancelText: options.cancelText ?? t("confirm.cancel"),
variant: options.variant ?? defaultState.variant,
})
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve
})
}, [])
}, [t])
return (
<ConfirmContext.Provider value={{ confirm }}>
+20 -18
View File
@@ -37,6 +37,7 @@ import {
import { EditorModeSwitch } from "./editor-mode-switch"
import { EditorToolbar } from "./toolbar"
import type { ContentMode, EditorToolbarAction, UploadImageHandler } from "./types"
import { useI18n } from "@/i18n/provider"
export type HtmlEditorRef = {
focus: () => void
@@ -73,6 +74,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
ref
) {
const t = useI18n()
const imageInputRef = useRef<HTMLInputElement>(null)
const [previewOnly, setPreviewOnly] = useState(false)
const proseClassName =
@@ -143,7 +145,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
return
}
const previousUrl = editor.getAttributes("link").href as string | undefined
const url = window.prompt("输入链接地址", previousUrl || "https://")
const url = window.prompt(t("editor.promptLinkUrl"), previousUrl || "https://")
if (url === null) {
return
}
@@ -190,7 +192,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "bold",
label: "粗体",
label: t("editor.bold"),
icon: BoldIcon,
disabled,
pressed: !!editor?.isActive("bold"),
@@ -198,7 +200,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "underline",
label: "下划线",
label: t("editor.underline"),
icon: UnderlineIcon,
disabled,
pressed: !!editor?.isActive("underline"),
@@ -206,7 +208,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "italic",
label: "斜体",
label: t("editor.italic"),
icon: ItalicIcon,
disabled,
pressed: !!editor?.isActive("italic"),
@@ -214,7 +216,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "strike",
label: "删除线",
label: t("editor.strike"),
icon: StrikethroughIcon,
disabled,
pressed: !!editor?.isActive("strike"),
@@ -223,7 +225,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
{ key: "separator-1", type: "separator" },
{
key: "h1",
label: "一级标题",
label: t("editor.heading1"),
icon: Heading1Icon,
disabled,
pressed: !!editor?.isActive("heading", { level: 1 }),
@@ -231,7 +233,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "h2",
label: "二级标题",
label: t("editor.heading2"),
icon: Heading2Icon,
disabled,
pressed: !!editor?.isActive("heading", { level: 2 }),
@@ -239,7 +241,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "quote",
label: "引用",
label: t("editor.quote"),
icon: QuoteIcon,
disabled,
pressed: !!editor?.isActive("blockquote"),
@@ -247,7 +249,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "bullet-list",
label: "无序列表",
label: t("editor.bulletList"),
icon: ListIcon,
disabled,
pressed: !!editor?.isActive("bulletList"),
@@ -255,7 +257,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "ordered-list",
label: "有序列表",
label: t("editor.orderedList"),
icon: ListOrderedIcon,
disabled,
pressed: !!editor?.isActive("orderedList"),
@@ -264,7 +266,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
{ key: "separator-2", type: "separator" },
{
key: "code",
label: "行内代码",
label: t("editor.inlineCode"),
icon: Code2Icon,
disabled,
pressed: !!editor?.isActive("code"),
@@ -272,7 +274,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "code-block",
label: "代码块",
label: t("editor.codeBlock"),
icon: Code2Icon,
disabled,
pressed: !!editor?.isActive("codeBlock"),
@@ -281,7 +283,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
{ key: "separator-3", type: "separator" },
{
key: "link",
label: "链接",
label: t("editor.link"),
icon: LinkIcon,
disabled,
pressed: !!editor?.isActive("link"),
@@ -289,7 +291,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
},
{
key: "image",
label: "图片",
label: t("editor.image"),
icon: ImageIcon,
disabled: disabled || !onUploadImage,
onClick: () => imageInputRef.current?.click(),
@@ -297,14 +299,14 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
{ key: "separator-4", type: "separator" },
{
key: "undo-tail",
label: "撤销",
label: t("editor.undo"),
icon: RotateCcwIcon,
disabled: disabled || !editor?.can().undo(),
onClick: () => editor?.chain().focus().undo().run(),
},
{
key: "redo-tail",
label: "重做",
label: t("editor.redo"),
icon: RedoIcon,
disabled: disabled || !editor?.can().redo(),
onClick: () => editor?.chain().focus().redo().run(),
@@ -312,7 +314,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
{ key: "separator-fullscreen", type: "separator" },
{
key: "fullscreen",
label: fullscreen ? "退出全屏" : "全屏",
label: fullscreen ? t("editor.exitFullscreen") : t("editor.fullscreen"),
icon: fullscreen ? Minimize2Icon : Maximize2Icon,
disabled,
pressed: fullscreen,
@@ -321,7 +323,7 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
{ key: "separator-preview", type: "separator" },
{
key: "preview-only",
label: "仅预览",
label: t("editor.previewOnly"),
icon: EyeIcon,
disabled,
pressed: previewOnly,
+4 -2
View File
@@ -14,6 +14,7 @@ import {
type ContentValue,
type UploadImageHandler,
} from "./types"
import { useI18n } from "@/i18n/provider"
type ContentEditorProps = {
value: ContentValue
@@ -55,6 +56,7 @@ export function ContentEditor({
height,
allowedModes = CONTENT_MODE_OPTIONS,
}: ContentEditorProps) {
const t = useI18n()
const editorHeight = normalizeHeight(height)
const [fullscreen, setFullscreen] = useState(false)
const [mounted, setMounted] = useState(false)
@@ -105,7 +107,7 @@ export function ContentEditor({
}
const confirmed = window.confirm(
`切换到 ${getModeLabel(nextMode)} 模式会尝试自动转换内容,复杂格式可能有损。是否继续?`
t("editor.modeSwitchConfirm", { mode: getModeLabel(nextMode) })
)
if (!confirmed) {
return
@@ -116,7 +118,7 @@ export function ContentEditor({
raw: convertContent(activeMode, value.raw),
})
},
[activeMode, disabled, normalizedAllowedModes, onChange, value.raw]
[activeMode, disabled, normalizedAllowedModes, onChange, t, value.raw]
)
useEffect(() => {
@@ -15,6 +15,7 @@ import "./markdown-editor.css"
import { EditorModeSwitch } from "./editor-mode-switch"
import type { ContentMode, UploadImageHandler } from "./types"
import { useI18n } from "@/i18n/provider"
export type MarkdownEditorRef = {
focus: () => void
@@ -51,6 +52,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
},
ref
) {
const t = useI18n()
const editorId = useId()
const editorRef = useRef<ExposeParam>(null)
const { resolvedTheme } = useTheme()
@@ -65,7 +67,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
/>,
<NormalToolbar
key="toggle-fullscreen"
title={fullscreen ? "退出全屏" : "全屏"}
title={fullscreen ? t("editor.exitFullscreen") : t("editor.fullscreen")}
disabled={disabled}
onClick={onToggleFullscreen}
>
@@ -76,7 +78,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
)}
</NormalToolbar>,
],
[allowedModes, disabled, fullscreen, mode, onModeChange, onToggleFullscreen]
[allowedModes, disabled, fullscreen, mode, onModeChange, onToggleFullscreen, t]
)
useImperativeHandle(ref, () => ({
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
@@ -12,7 +12,6 @@ import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -24,6 +23,7 @@ import {
FieldLabel,
} from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import { useI18n } from "@/i18n/provider"
type ConversationCloseDialogProps = {
open: boolean
@@ -32,17 +32,9 @@ type ConversationCloseDialogProps = {
onSuccess?: () => Promise<void> | void
}
const closeSchema = z.object({
closeReason: z.string().trim().min(1, "请输入关闭原因"),
})
type CloseForm = z.infer<typeof closeSchema>
const closeResolver = zodResolver(closeSchema as never) as Resolver<
z.input<typeof closeSchema>,
undefined,
z.output<typeof closeSchema>
>
type CloseForm = {
closeReason: string
}
const emptyForm: CloseForm = {
closeReason: "",
@@ -79,12 +71,22 @@ function ConversationCloseDialogBody({
onOpenChange,
onSuccess,
}: ConversationCloseDialogBodyProps) {
const t = useI18n()
const [saving, setSaving] = useState(false)
const form = useForm<
z.input<typeof closeSchema>,
undefined,
z.output<typeof closeSchema>
>({
const closeSchema = useMemo(
() =>
z.object({
closeReason: z.string().trim().min(1, t("conversationAction.closeReasonRequired")),
}),
[t]
)
const closeResolver = useMemo(
() => zodResolver(closeSchema as never) as Resolver<CloseForm>,
[closeSchema]
)
const form = useForm<CloseForm>({
resolver: closeResolver,
defaultValues: emptyForm,
})
@@ -101,19 +103,19 @@ function ConversationCloseDialogBody({
async function onFormSubmit(values: CloseForm) {
if (!conversationId) {
toast.error("会话不存在")
toast.error(t("conversationAction.conversationMissing"))
return
}
setSaving(true)
try {
await closeConversation(conversationId, values.closeReason.trim())
toast.success(`已关闭会话:#${conversationId}`)
toast.success(t("conversationAction.closed", { id: conversationId }))
reset(emptyForm)
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "关闭会话失败")
toast.error(error instanceof Error ? error.message : t("conversationAction.closeFailed"))
} finally {
setSaving(false)
}
@@ -122,20 +124,19 @@ function ConversationCloseDialogBody({
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>关闭会话</DialogTitle>
{/* <DialogDescription>
当前会话:{conversationId ? `#${conversationId}` : "-"}
</DialogDescription> */}
<DialogTitle>{t("conversationAction.closeTitle")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.closeReason}>
<FieldLabel htmlFor="conversation-close-reason">关闭原因</FieldLabel>
<FieldLabel htmlFor="conversation-close-reason">
{t("conversationAction.closeReason")}
</FieldLabel>
<FieldContent>
<Textarea
id="conversation-close-reason"
rows={4}
placeholder="填写关闭原因,关闭后会写入操作记录"
placeholder={t("conversationAction.closeReasonPlaceholder")}
aria-invalid={!!errors.closeReason}
{...register("closeReason")}
/>
@@ -150,11 +151,11 @@ function ConversationCloseDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("conversationAction.cancel")}
</Button>
<Button type="submit" disabled={saving}>
<CircleXIcon />
{saving ? "关闭中..." : "确认关闭"}
{saving ? t("conversationAction.closing") : t("conversationAction.confirmClose")}
</Button>
</DialogFooter>
</form>
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrowRightLeftIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
@@ -29,6 +29,7 @@ import {
fetchAgentProfilesAll,
type AdminAgentProfile,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type ConversationTransferDialogProps = {
open: boolean
@@ -38,24 +39,16 @@ type ConversationTransferDialogProps = {
onSuccess?: () => Promise<void> | void
}
const transferSchema = z.object({
toUserId: z.string().trim().min(1, "请选择目标客服"),
reason: z.string().trim(),
})
type TransferForm = z.infer<typeof transferSchema>
type TransferForm = {
toUserId: string
reason: string
}
const emptyForm: TransferForm = {
toUserId: "",
reason: "",
}
const transferResolver = zodResolver(transferSchema as never) as Resolver<
z.input<typeof transferSchema>,
undefined,
z.output<typeof transferSchema>
>
export function ConversationTransferDialog({
open,
mode,
@@ -91,19 +84,29 @@ function ConversationTransferDialogBody({
onOpenChange,
onSuccess,
}: ConversationTransferDialogBodyProps) {
const t = useI18n()
const [saving, setSaving] = useState(false)
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}`,
label: agent.displayName || agent.nickname || agent.username || t("conversationAction.agentFallback", { id: agent.userId }),
}))
const form = useForm<
z.input<typeof transferSchema>,
undefined,
z.output<typeof transferSchema>
>({
const transferSchema = useMemo(
() =>
z.object({
toUserId: z.string().trim().min(1, t("conversationAction.targetAgentRequired")),
reason: z.string().trim(),
}),
[t]
)
const transferResolver = useMemo(
() => zodResolver(transferSchema as never) as Resolver<TransferForm>,
[transferSchema]
)
const form = useForm<TransferForm>({
resolver: transferResolver,
defaultValues: emptyForm,
})
@@ -126,16 +129,16 @@ function ConversationTransferDialogBody({
setAgents(data.filter((item) => item.serviceStatus === 0))
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
toast.error(error instanceof Error ? error.message : t("conversationAction.loadAgentsFailed"))
})
.finally(() => {
setLoadingAgents(false)
})
}, [])
}, [t])
async function onFormSubmit(values: TransferForm) {
if (!conversationId) {
toast.error("会话不存在")
toast.error(t("conversationAction.conversationMissing"))
return
}
@@ -146,16 +149,22 @@ function ConversationTransferDialogBody({
try {
if (mode === "assign") {
await assignConversation(conversationId, toUserId, reason)
toast.success(`已分配会话:#${conversationId}`)
toast.success(t("conversationAction.assigned", { id: conversationId }))
} else {
await transferConversation(conversationId, toUserId, reason)
toast.success(`已转接会话:#${conversationId}`)
toast.success(t("conversationAction.transferred", { id: conversationId }))
}
reset(emptyForm)
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : mode === "assign" ? "分配会话失败" : "转接会话失败")
toast.error(
error instanceof Error
? error.message
: mode === "assign"
? t("conversationAction.assignFailed")
: t("conversationAction.transferFailed")
)
} finally {
setSaving(false)
}
@@ -166,15 +175,16 @@ function ConversationTransferDialogBody({
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{isAssign ? "分配会话" : "转接会话"}</DialogTitle>
{/* <DialogDescription>
当前会话:{conversationId ? `#${conversationId}` : "-"}
</DialogDescription> */}
<DialogTitle>
{isAssign ? t("conversationAction.assignTitle") : t("conversationAction.transferTitle")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.toUserId}>
<FieldLabel htmlFor="conversation-transfer-user">目标客服</FieldLabel>
<FieldLabel htmlFor="conversation-transfer-user">
{t("conversationAction.targetAgent")}
</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -183,9 +193,13 @@ function ConversationTransferDialogBody({
<OptionCombobox
value={field.value}
options={userOptions}
placeholder={loadingAgents ? "加载中..." : "选择目标客服"}
searchPlaceholder="搜索客服"
emptyText="暂无可选客服"
placeholder={
loadingAgents
? t("conversationAction.loading")
: t("conversationAction.selectTargetAgent")
}
searchPlaceholder={t("conversationAction.searchAgent")}
emptyText={t("conversationAction.emptyAgents")}
disabled={saving || loadingAgents}
onChange={field.onChange}
/>
@@ -196,13 +210,17 @@ function ConversationTransferDialogBody({
</Field>
<Field data-invalid={!!errors.reason}>
<FieldLabel htmlFor="conversation-transfer-reason">
{isAssign ? "分配说明" : "转接原因"}
{isAssign ? t("conversationAction.assignNote") : t("conversationAction.transferReason")}
</FieldLabel>
<FieldContent>
<Textarea
id="conversation-transfer-reason"
rows={4}
placeholder={isAssign ? "填写分配说明,便于后续追踪" : "填写转接原因,便于后续追踪"}
placeholder={
isAssign
? t("conversationAction.assignPlaceholder")
: t("conversationAction.transferPlaceholder")
}
aria-invalid={!!errors.reason}
{...register("reason")}
/>
@@ -217,11 +235,17 @@ function ConversationTransferDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("conversationAction.cancel")}
</Button>
<Button type="submit" disabled={saving}>
<ArrowRightLeftIcon />
{saving ? (isAssign ? "分配中..." : "转接中...") : isAssign ? "确认分配" : "确认转接"}
{saving
? isAssign
? t("conversationAction.assigning")
: t("conversationAction.transferring")
: isAssign
? t("conversationAction.confirmAssign")
: t("conversationAction.confirmTransfer")}
</Button>
</DialogFooter>
</form>
+6 -4
View File
@@ -8,6 +8,7 @@ import {
} from "@/components/customer-form"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import { useI18n } from "@/i18n/provider"
export type CustomerFormDialogProps = {
open: boolean
@@ -17,7 +18,7 @@ export type CustomerFormDialogProps = {
onSave: (payload: CustomerFormSavePayload) => Promise<void>
}
/** 客户新建/编辑表单弹窗(ProjectDialog + CustomerForm),供客户管理页与会话工作台等复用。 */
/** Customer create/edit dialog shared by customer management and conversation workflows. */
export function CustomerFormDialog({
open,
saving,
@@ -45,6 +46,7 @@ function CustomerFormDialogBody({
onOpenChange,
onSave,
}: CustomerFormDialogBodyProps) {
const t = useI18n()
const formId = "customer-form-dialog"
const [loadingDetail, setLoadingDetail] = useState(() => Boolean(itemId))
@@ -52,7 +54,7 @@ function CustomerFormDialogBody({
<ProjectDialog
open
onOpenChange={(next) => onOpenChange(next)}
title={itemId ? "编辑客户" : "新建客户"}
title={itemId ? t("customerForm.editTitle") : t("customerForm.createTitle")}
allowFullscreen
size="xl"
footer={
@@ -63,14 +65,14 @@ function CustomerFormDialogBody({
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
{t("customerForm.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={saving || loadingDetail}
>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
{saving ? t("customerForm.saving") : itemId ? t("customerForm.save") : t("customerForm.create")}
</Button>
</>
}
+82 -90
View File
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import {
Controller,
@@ -13,6 +13,7 @@ import { PlusIcon, Trash2Icon } from "lucide-react"
import { z } from "zod/v4"
import { CompanyPicker } from "@/components/company-picker"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Field,
@@ -21,13 +22,6 @@ import {
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 { fetchCustomerContacts, type AdminCustomerContact } from "@/lib/api/customer-contact"
import {
@@ -35,15 +29,8 @@ import {
type AdminCustomer,
type SaveCustomerProfilePayload,
} from "@/lib/api/customer"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { ContactType, ContactTypeLabels, Gender, GenderLabels } from "@/lib/generated/enums"
const genderOptions = [
...getEnumOptions(GenderLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
] as const
import { ContactType, Gender } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
const genderValueOptions = [
String(Gender.Unknown),
@@ -65,15 +52,13 @@ const contactRowSchema = z.object({
isPrimary: z.boolean(),
})
const customerFormSchema = z.object({
name: z.string().trim().min(1, "客户名称不能为空"),
gender: z.enum(genderValueOptions, { message: "请选择性别" }),
companyId: z.string().trim().regex(/^\d+$/, "请选择所属公司"),
remark: z.string().trim(),
contacts: z.array(contactRowSchema),
})
export type CustomerFormValues = z.infer<typeof customerFormSchema>
export type CustomerFormValues = {
name: string
gender: (typeof genderValueOptions)[number]
companyId: string
remark: string
contacts: CustomerContactFormRow[]
}
export type CustomerContactFormRow = {
id?: number
@@ -83,12 +68,6 @@ export type CustomerContactFormRow = {
isPrimary: boolean
}
const customerFormResolver = zodResolver(customerFormSchema as never) as Resolver<
z.input<typeof customerFormSchema>,
undefined,
z.output<typeof customerFormSchema>
>
function defaultContactRow(isPrimary: boolean): CustomerContactFormRow {
return {
contactType: ContactType.Mobile,
@@ -136,7 +115,7 @@ function buildContactsFromApi(list: AdminCustomerContact[]): CustomerContactForm
}))
}
/** 过滤空行并保证至多一条主联系方式(有一条有值时至少一条主) */
/** Filters empty rows and keeps at most one primary contact. */
export function normalizeContactsForSubmit(rows: CustomerContactFormRow[]): CustomerContactFormRow[] {
const withValue = rows.filter((r) => r.contactValue.trim() !== "")
if (withValue.length === 0) {
@@ -154,14 +133,6 @@ export function normalizeContactsForSubmit(rows: CustomerContactFormRow[]): Cust
export type CustomerFormSavePayload = SaveCustomerProfilePayload
function getGenderLabel(value: string) {
return getEnumLabel(GenderLabels, Number(value) as Gender)
}
function getContactTypeLabel(value: string) {
return ContactTypeLabels[value as ContactType] ?? value
}
type CustomerFormFieldsProps = {
form: UseFormReturn<CustomerFormValues>
fieldIdPrefix?: string
@@ -173,6 +144,7 @@ function CustomerFormFields({
fieldIdPrefix = "customer",
remarkRows = 4,
}: CustomerFormFieldsProps) {
const t = useI18n()
const {
control,
register,
@@ -182,6 +154,22 @@ function CustomerFormFields({
getValues,
} = form
const { fields, append, remove } = useFieldArray({ control, name: "contacts" })
const genderOptions = useMemo(
() => [
{ value: String(Gender.Unknown), label: t("customerForm.genderUnknown") },
{ value: String(Gender.Male), label: t("customerForm.genderMale") },
{ value: String(Gender.Female), label: t("customerForm.genderFemale") },
],
[t]
)
const contactTypeOptions = useMemo(
() => [
{ value: ContactType.Mobile, label: t("customerForm.contactMobile") },
{ value: ContactType.Email, label: t("customerForm.contactEmail") },
{ value: ContactType.Other, label: t("customerForm.contactOther") },
],
[t]
)
const id = (suffix: string) => `${fieldIdPrefix}-${suffix}`
@@ -211,14 +199,14 @@ function CustomerFormFields({
return (
<div className="space-y-8">
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground">客户信息</h3>
<h3 className="text-sm font-semibold text-muted-foreground">{t("customerForm.sectionCustomer")}</h3>
<div className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor={id("name")}>客户名称</FieldLabel>
<FieldLabel htmlFor={id("name")}>{t("customerForm.name")}</FieldLabel>
<FieldContent>
<Input
id={id("name")}
placeholder="请输入客户名称"
placeholder={t("customerForm.namePlaceholder")}
aria-invalid={!!errors.name}
autoComplete="off"
{...register("name")}
@@ -229,24 +217,18 @@ function CustomerFormFields({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.gender}>
<FieldLabel htmlFor={id("gender")}>性别</FieldLabel>
<FieldLabel htmlFor={id("gender")}>{t("customerForm.gender")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="gender"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} modal={false}>
<SelectTrigger id={id("gender")}>
<SelectValue>{getGenderLabel(field.value)}</SelectValue>
</SelectTrigger>
<SelectContent>
{genderOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<OptionCombobox
value={field.value}
options={genderOptions}
placeholder={t("customerForm.gender")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.gender]} />
@@ -254,7 +236,7 @@ function CustomerFormFields({
</Field>
<Field data-invalid={!!errors.companyId}>
<FieldLabel htmlFor={id("company")}>所属公司</FieldLabel>
<FieldLabel htmlFor={id("company")}>{t("customerForm.company")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -272,11 +254,11 @@ function CustomerFormFields({
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor={id("remark")}>备注</FieldLabel>
<FieldLabel htmlFor={id("remark")}>{t("customerForm.remark")}</FieldLabel>
<FieldContent>
<Textarea
id={id("remark")}
placeholder="可选"
placeholder={t("customerForm.optional")}
rows={remarkRows}
aria-invalid={!!errors.remark}
{...register("remark")}
@@ -288,13 +270,13 @@ function CustomerFormFields({
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground">联系方式</h3>
<h3 className="text-sm font-semibold text-muted-foreground">{t("customerForm.sectionContacts")}</h3>
<div className="hidden gap-2 border-b border-border pb-2 text-xs font-medium text-muted-foreground sm:grid sm:grid-cols-[108px_minmax(0,1fr)_minmax(0,1fr)_5.5rem_2.25rem] sm:items-center sm:gap-x-2">
<span>类型</span>
<span>联系方式</span>
<span>备注</span>
<span className="text-center">主</span>
<span className="sr-only">操作</span>
<span>{t("customerForm.type")}</span>
<span>{t("customerForm.contact")}</span>
<span>{t("customerForm.remark")}</span>
<span className="text-center">{t("customerForm.primary")}</span>
<span className="sr-only">{t("customerForm.actions")}</span>
</div>
<div className="space-y-1">
@@ -306,35 +288,29 @@ function CustomerFormFields({
className="grid grid-cols-1 gap-2 border-b border-border py-2 last:border-b-0 sm:grid-cols-[108px_minmax(0,1fr)_minmax(0,1fr)_5.5rem_2.25rem] sm:items-center sm:gap-x-2"
>
<div className="min-w-0 space-y-1 sm:space-y-0">
<span className="text-xs text-muted-foreground sm:hidden">类型</span>
<span className="text-xs text-muted-foreground sm:hidden">{t("customerForm.type")}</span>
<Controller
control={control}
name={`contacts.${index}.contactType`}
render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange} modal={false}>
<SelectTrigger className="w-full" id={id(`ct-${index}`)}>
<SelectValue>{getContactTypeLabel(f.value)}</SelectValue>
</SelectTrigger>
<SelectContent>
{contactTypeValues.map((v) => (
<SelectItem key={v} value={v}>
{getContactTypeLabel(v)}
</SelectItem>
))}
</SelectContent>
</Select>
<OptionCombobox
value={f.value}
options={contactTypeOptions}
placeholder={t("customerForm.type")}
onChange={f.onChange}
/>
)}
/>
</div>
<Field data-invalid={!!err?.contactValue} className="min-w-0 gap-1 sm:gap-0">
<FieldLabel className="text-xs text-muted-foreground sm:sr-only">联系方式</FieldLabel>
<FieldLabel className="text-xs text-muted-foreground sm:sr-only">{t("customerForm.contact")}</FieldLabel>
<FieldContent>
<Input
placeholder={
watch(`contacts.${index}.contactType`) === ContactType.Email
? "邮箱"
: "号码 / 账号"
? t("customerForm.emailPlaceholder")
: t("customerForm.contactPlaceholder")
}
aria-invalid={!!err?.contactValue}
{...register(`contacts.${index}.contactValue`)}
@@ -345,19 +321,19 @@ function CustomerFormFields({
<Field className="min-w-0 gap-1 sm:gap-0">
<FieldLabel htmlFor={id(`tag-${index}`)} className="text-xs text-muted-foreground sm:sr-only">
备注
{t("customerForm.remark")}
</FieldLabel>
<FieldContent>
<Input
id={id(`tag-${index}`)}
placeholder="可选"
placeholder={t("customerForm.optional")}
{...register(`contacts.${index}.remark`)}
/>
</FieldContent>
</Field>
<div className="flex items-center justify-start gap-2 sm:justify-center">
<span className="text-xs text-muted-foreground sm:hidden">主联系方式</span>
<span className="text-xs text-muted-foreground sm:hidden">{t("customerForm.primaryContact")}</span>
<input
type="radio"
className="size-4 shrink-0 accent-primary"
@@ -365,10 +341,10 @@ function CustomerFormFields({
checked={watch(`contacts.${index}.isPrimary`)}
onChange={() => setPrimaryIndex(index)}
id={id(`primary-${index}`)}
aria-label="设为主联系方式"
aria-label={t("customerForm.setPrimary")}
/>
<label htmlFor={id(`primary-${index}`)} className="hidden cursor-pointer text-sm sm:inline">
主
{t("customerForm.primary")}
</label>
</div>
@@ -379,7 +355,7 @@ function CustomerFormFields({
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => removeContactRow(index)}
aria-label="删除此条联系方式"
aria-label={t("customerForm.deleteContact")}
>
<Trash2Icon className="size-4" />
</Button>
@@ -391,7 +367,7 @@ function CustomerFormFields({
<Button type="button" variant="outline" size="sm" className="gap-1" onClick={addContactRow}>
<PlusIcon className="size-4" />
添加联系方式
{t("customerForm.addContact")}
</Button>
</div>
</div>
@@ -417,7 +393,23 @@ export function CustomerForm({
className,
onLoadingDetailChange,
}: CustomerFormProps) {
const t = useI18n()
const [loadingDetail, setLoadingDetail] = useState(() => Boolean(itemId))
const customerFormSchema = useMemo(
() =>
z.object({
name: z.string().trim().min(1, t("customerForm.nameRequired")),
gender: z.enum(genderValueOptions, { message: t("customerForm.genderRequired") }),
companyId: z.string().trim().regex(/^\d+$/, t("customerForm.companyRequired")),
remark: z.string().trim(),
contacts: z.array(contactRowSchema),
}),
[t]
)
const customerFormResolver = useMemo(
() => zodResolver(customerFormSchema as never) as Resolver<CustomerFormValues>,
[customerFormSchema]
)
const form = useForm<CustomerFormValues>({
resolver: customerFormResolver,
@@ -481,7 +473,7 @@ export function CustomerForm({
if (loadingDetail) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">加载中...</div>
<div className="text-muted-foreground">{t("customerForm.loading")}</div>
</div>
)
}
@@ -10,15 +10,16 @@ import { Input } from "@/components/ui/input"
import { linkConversationToCustomer } from "@/lib/api/agent"
import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer"
import { linkTicketToCustomer } from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
export type CustomerLinkOrCreateDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** 传入时会话侧:关联已有或新建后绑定该会话 */
/** When present, links an existing or newly created customer to this conversation. */
conversationId?: number | null
/** 传入时工单侧:关联已有或新建后绑定该工单 */
/** When present, links an existing or newly created customer to this ticket. */
ticketId?: number | null
/** 绑定成功或仅新建成功后的回调 */
/** Called after linking succeeds, or after creating without a linked context. */
onSuccess?: () => void | Promise<void>
}
@@ -31,6 +32,7 @@ export function CustomerLinkOrCreateDialog({
ticketId,
onSuccess,
}: CustomerLinkOrCreateDialogProps) {
const t = useI18n()
const [searchText, setSearchText] = useState("")
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<AdminCustomer[]>([])
@@ -51,7 +53,7 @@ export function CustomerLinkOrCreateDialog({
const runSearch = async () => {
const q = searchText.trim()
if (!q) {
toast.error("请输入关键词(姓名、手机、邮箱、公司、联系方式等)")
toast.error(t("customerLink.keywordRequired"))
return
}
setSearching(true)
@@ -64,18 +66,19 @@ export function CustomerLinkOrCreateDialog({
})
setResults(data.results)
if (data.results.length === 0) {
toast.message("未找到匹配客户,可点击下方填写新客户")
toast.message(t("customerLink.noMatch"))
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "搜索失败")
toast.error(e instanceof Error ? e.message : t("customerLink.searchFailed"))
} finally {
setSearching(false)
}
}
const handleLinkExisting = async (customer: AdminCustomer) => {
const customerName = customer.name || t("customerLink.fallbackName", { id: customer.id })
if (!conversationId && !ticketId) {
toast.success(`已选择客户:${customer.name || `#${customer.id}`}`)
toast.success(t("customerLink.selected", { name: customerName }))
onOpenChange(false)
await onSuccess?.()
return
@@ -94,11 +97,11 @@ export function CustomerLinkOrCreateDialog({
customerId: customer.id,
})
}
toast.success("已关联客户")
toast.success(t("customerLink.linked"))
onOpenChange(false)
await onSuccess?.()
} catch (e) {
toast.error(e instanceof Error ? e.message : "关联失败")
toast.error(e instanceof Error ? e.message : t("customerLink.linkFailed"))
} finally {
setLinkingId(null)
}
@@ -121,14 +124,18 @@ export function CustomerLinkOrCreateDialog({
})
}
if (conversationId || ticketId) {
toast.success(conversationId ? "已创建客户并关联当前会话" : "已创建客户并关联当前工单")
toast.success(
conversationId
? t("customerLink.createdAndLinkedConversation")
: t("customerLink.createdAndLinkedTicket")
)
} else {
toast.success("已创建客户")
toast.success(t("customerLink.created"))
}
onOpenChange(false)
await onSuccess?.()
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败")
toast.error(e instanceof Error ? e.message : t("customerLink.saveFailed"))
} finally {
setSaving(false)
}
@@ -136,14 +143,18 @@ export function CustomerLinkOrCreateDialog({
const description = (
<>
先搜索已有客户;
{t("customerLink.descriptionPrefix")}
{conversationId || ticketId
? `选中即可关联当前${conversationId ? "会话" : "工单"}。`
: "未接入上下文时仅创建或定位客户。"}
若无结果,可填写下方新客户
? conversationId
? t("customerLink.descriptionLinkConversation")
: t("customerLink.descriptionLinkTicket")
: t("customerLink.descriptionNoContext")}
{t("customerLink.descriptionCreatePrefix")}
{conversationId || ticketId
? `,保存后将自动关联${conversationId ? "会话" : "工单"}。`
: "。"}
? conversationId
? t("customerLink.descriptionCreateConversation")
: t("customerLink.descriptionCreateTicket")
: t("customerLink.descriptionCreateNoContext")}
</>
)
@@ -151,7 +162,7 @@ export function CustomerLinkOrCreateDialog({
<ProjectDialog
open={open}
onOpenChange={(nextOpen) => onOpenChange(nextOpen)}
title="关联或创建客户"
title={t("customerLink.title")}
description={description}
allowFullscreen
size="xl"
@@ -162,15 +173,17 @@ export function CustomerLinkOrCreateDialog({
variant="outline"
onClick={() => onOpenChange(false)}
>
关闭
{t("customerLink.close")}
</Button>
{showCreate ? (
<Button type="submit" form={createFormId} disabled={saving}>
{saving
? "提交中…"
? t("customerLink.submitting")
: conversationId
? "创建并关联会话"
: "创建客户"}
? t("customerLink.createAndLinkConversation")
: ticketId
? t("customerLink.createAndLinkTicket")
: t("customerLink.createCustomer")}
</Button>
) : null}
</div>
@@ -179,7 +192,7 @@ export function CustomerLinkOrCreateDialog({
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Input
placeholder="姓名 / 手机 / 邮箱 / 公司 / 联系方式"
placeholder={t("customerLink.searchPlaceholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
@@ -195,7 +208,7 @@ export function CustomerLinkOrCreateDialog({
disabled={searching}
onClick={() => void runSearch()}
>
{searching ? "搜索中…" : "搜索"}
{searching ? t("customerLink.searching") : t("customerLink.search")}
</Button>
</div>
@@ -208,7 +221,7 @@ export function CustomerLinkOrCreateDialog({
>
<div className="min-w-0">
<div className="truncate font-medium flex items-center gap-2">
<span>{row.name || `客户 #${row.id}`}</span>
<span>{row.name || t("customerLink.fallbackName", { id: row.id })}</span>
<span className="text-muted-foreground">
{row.primaryMobile}
</span>
@@ -231,10 +244,10 @@ export function CustomerLinkOrCreateDialog({
onClick={() => void handleLinkExisting(row)}
>
{linkingId === row.id
? "处理中…"
? t("customerLink.processing")
: conversationId
? "关联"
: "选用"}
? t("customerLink.link")
: t("customerLink.select")}
</Button>
</li>
))}
@@ -247,7 +260,7 @@ export function CustomerLinkOrCreateDialog({
className="text-sm text-primary underline-offset-4 hover:underline"
onClick={() => setShowCreate((v) => !v)}
>
{showCreate ? "收起新建表单" : "未找到?填写新客户"}
{showCreate ? t("customerLink.collapseCreate") : t("customerLink.showCreate")}
</button>
</div>
+8 -3
View File
@@ -1,7 +1,10 @@
"use client"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import { TableCell, TableRow } from "@/components/ui/table"
import { useI18n } from "@/i18n/provider"
export function DashboardPage({
className,
@@ -65,18 +68,20 @@ export function DashboardTableShell({
export function DashboardTableStateRow({
colSpan,
loading,
loadingText = "正在加载数据...",
emptyText = "暂无数据",
loadingText,
emptyText,
}: {
colSpan: number
loading?: boolean
loadingText?: string
emptyText?: string
}) {
const t = useI18n()
return (
<TableRow>
<TableCell colSpan={colSpan} className="py-12 text-center text-muted-foreground">
{loading ? loadingText : emptyText}
{loading ? (loadingText ?? t("common.loadingData")) : (emptyText ?? t("common.emptyData"))}
</TableCell>
</TableRow>
)
+9 -4
View File
@@ -1,3 +1,5 @@
"use client"
import { ArrowUpRightIcon, Clock3Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -8,6 +10,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { useI18n } from "@/i18n/provider"
type DashboardPlaceholderProps = {
eyebrow: string
@@ -22,6 +25,8 @@ export function DashboardPlaceholder({
description,
nextSteps,
}: DashboardPlaceholderProps) {
const t = useI18n()
return (
<div className="flex flex-1 flex-col gap-6 p-4 pt-4 lg:p-6 lg:pt-6">
<Card className="border-dashed">
@@ -36,7 +41,7 @@ export function DashboardPlaceholder({
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-[1.2fr_0.8fr]">
<div className="rounded-2xl border bg-muted/40 p-5">
<p className="text-sm font-medium">建议下一步</p>
<p className="text-sm font-medium">{t("placeholder.nextSteps")}</p>
<div className="mt-4 grid gap-3">
{nextSteps.map((item) => (
<div
@@ -51,13 +56,13 @@ export function DashboardPlaceholder({
</div>
<div className="flex flex-col justify-between rounded-2xl border bg-background p-5">
<div>
<p className="text-sm font-medium">状态</p>
<p className="text-sm font-medium">{t("placeholder.status")}</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
当前模块已完成页面骨架,可直接接入真实 API、表单弹窗与列表查询。
{t("placeholder.statusDescription")}
</p>
</div>
<Button variant="outline" className="mt-6 justify-between">
进入下一阶段开发
{t("placeholder.nextPhase")}
<ArrowUpRightIcon />
</Button>
</div>
+21 -19
View File
@@ -32,6 +32,7 @@ import {
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
import { useI18n } from "@/i18n/provider"
type DashboardTask = {
id: number
@@ -43,6 +44,7 @@ type DashboardTask = {
}
export function DataTable({ data }: { data: DashboardTask[] }) {
const t = useI18n()
return (
<Tabs
defaultValue="modules"
@@ -50,29 +52,29 @@ export function DataTable({ data }: { data: DashboardTask[] }) {
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl">模块推进看板</CardTitle>
<CardTitle className="text-xl">{t("scaffold.moduleBoard")}</CardTitle>
<CardDescription className="mt-1">
这里是后台一期的功能骨架清单,后续可替换为真实接口列表。
{t("scaffold.moduleBoardDescription")}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Input className="w-full md:w-64" placeholder="搜索模块名称" />
<Input className="w-full md:w-64" placeholder={t("scaffold.searchModule")} />
<Button variant="outline">
<FilterIcon />
筛选
{t("scaffold.filter")}
</Button>
</div>
</div>
<TabsList className="w-fit">
<TabsTrigger value="modules">模块列表</TabsTrigger>
<TabsTrigger value="milestones">里程碑</TabsTrigger>
<TabsTrigger value="modules">{t("scaffold.moduleList")}</TabsTrigger>
<TabsTrigger value="milestones">{t("scaffold.milestones")}</TabsTrigger>
</TabsList>
<TabsContent value="modules" className="m-0">
<Card>
<CardHeader>
<CardTitle>一期模块拆解</CardTitle>
<CardTitle>{t("scaffold.phaseOneModules")}</CardTitle>
<CardDescription>
覆盖账号权限、知识库、渠道接入与 Skill 能力入口。
{t("scaffold.phaseOneDescription")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -80,11 +82,11 @@ export function DataTable({ data }: { data: DashboardTask[] }) {
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>模块</TableHead>
<TableHead>负责人</TableHead>
<TableHead>状态</TableHead>
<TableHead>完成度</TableHead>
<TableHead className="text-right">更新时间</TableHead>
<TableHead>{t("scaffold.module")}</TableHead>
<TableHead>{t("scaffold.owner")}</TableHead>
<TableHead>{t("scaffold.status")}</TableHead>
<TableHead>{t("scaffold.progress")}</TableHead>
<TableHead className="text-right">{t("scaffold.updatedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -94,7 +96,7 @@ export function DataTable({ data }: { data: DashboardTask[] }) {
<TableCell>{item.owner}</TableCell>
<TableCell>
<Badge variant="outline" className="px-1.5">
{item.status === "已完成" ? (
{item.status === t("scaffold.done") ? (
<CircleCheckIcon className="fill-green-500 text-green-500" />
) : (
<Clock3Icon className="text-amber-500" />
@@ -117,16 +119,16 @@ export function DataTable({ data }: { data: DashboardTask[] }) {
<TabsContent value="milestones" className="m-0">
<Card className="border-dashed">
<CardHeader>
<CardTitle>下一阶段里程碑</CardTitle>
<CardTitle>{t("scaffold.nextMilestone")}</CardTitle>
<CardDescription>
当前已完成 UI 基础骨架,下一步接入真实 API 和业务表单。
{t("scaffold.nextMilestoneDescription")}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{[
"打通登录、用户、角色、权限列表接口。",
"补充表单弹窗、分页查询与错误处理。",
"接入知识库与渠道模块的实际配置能力。",
t("scaffold.milestoneLogin"),
t("scaffold.milestoneForms"),
t("scaffold.milestoneKnowledge"),
].map((item) => (
<div
key={item}
+12 -10
View File
@@ -16,6 +16,7 @@ import {
import { EditorToolbar } from "./toolbar"
import type { BaseEditorProps } from "./types"
import { useI18n } from "@/i18n/provider"
export type HtmlEditorProps = BaseEditorProps & {
value: string
@@ -25,9 +26,11 @@ export type HtmlEditorProps = BaseEditorProps & {
export function HtmlEditor({
value,
onChange,
placeholder = "请输入内容...",
placeholder,
disabled = false,
}: HtmlEditorProps) {
const t = useI18n()
const editorPlaceholder = placeholder ?? t("editor.placeholder")
const editor = useEditor({
immediatelyRender: false,
extensions: [
@@ -45,7 +48,7 @@ export function HtmlEditor({
},
}),
Placeholder.configure({
placeholder,
placeholder: editorPlaceholder,
}),
],
content: value,
@@ -80,14 +83,14 @@ export function HtmlEditor({
const toolbarActions = [
{
key: "undo",
label: "撤销",
label: t("editor.undo"),
icon: UndoIcon,
disabled: !editor.can().undo() || disabled,
onClick: () => editor.chain().focus().undo().run(),
},
{
key: "redo",
label: "重做",
label: t("editor.redo"),
icon: RedoIcon,
disabled: !editor.can().redo() || disabled,
onClick: () => editor.chain().focus().redo().run(),
@@ -95,7 +98,7 @@ export function HtmlEditor({
{ key: "separator-1", type: "separator" as const },
{
key: "bold",
label: "粗体",
label: t("editor.bold"),
icon: BoldIcon,
disabled,
pressed: editor.isActive("bold"),
@@ -103,7 +106,7 @@ export function HtmlEditor({
},
{
key: "italic",
label: "斜体",
label: t("editor.italic"),
icon: ItalicIcon,
disabled,
pressed: editor.isActive("italic"),
@@ -112,7 +115,7 @@ export function HtmlEditor({
{ key: "separator-2", type: "separator" as const },
{
key: "bulletList",
label: "无序列表",
label: t("editor.bulletList"),
icon: ListIcon,
disabled,
pressed: editor.isActive("bulletList"),
@@ -120,7 +123,7 @@ export function HtmlEditor({
},
{
key: "orderedList",
label: "有序列表",
label: t("editor.orderedList"),
icon: ListOrderedIcon,
disabled,
pressed: editor.isActive("orderedList"),
@@ -128,7 +131,7 @@ export function HtmlEditor({
},
{
key: "blockquote",
label: "引用",
label: t("editor.quote"),
icon: QuoteIcon,
disabled,
pressed: editor.isActive("blockquote"),
@@ -145,4 +148,3 @@ export function HtmlEditor({
</div>
)
}
+11 -10
View File
@@ -16,6 +16,7 @@ import { Textarea } from "@/components/ui/textarea"
import { EditorToolbar } from "./toolbar"
import type { BaseEditorProps } from "./types"
import { useI18n } from "@/i18n/provider"
export type MarkdownEditorProps = BaseEditorProps & {
value: string
@@ -31,6 +32,7 @@ export function MarkdownEditor({
rows = 16,
className,
}: MarkdownEditorProps) {
const t = useI18n()
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const handleWrapSelection = (prefix: string, suffix = prefix) => {
@@ -79,7 +81,7 @@ export function MarkdownEditor({
}
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? 0
const selected = value.slice(start, end) || "链接文本"
const selected = value.slice(start, end) || t("editor.linkText")
const markdown = `[${selected}](https://)`
const next = `${value.slice(0, start)}${markdown}${value.slice(end)}`
onChange(next)
@@ -93,7 +95,7 @@ export function MarkdownEditor({
const toolbarActions = [
{
key: "heading1",
label: "一级标题",
label: t("editor.heading1"),
icon: Heading1Icon,
disabled,
onClick: () => handleInsertLinePrefix("# "),
@@ -101,21 +103,21 @@ export function MarkdownEditor({
{ key: "separator-1", type: "separator" as const },
{
key: "bold",
label: "粗体",
label: t("editor.bold"),
icon: BoldIcon,
disabled,
onClick: () => handleWrapSelection("**"),
},
{
key: "italic",
label: "斜体",
label: t("editor.italic"),
icon: ItalicIcon,
disabled,
onClick: () => handleWrapSelection("*"),
},
{
key: "code",
label: "行内代码",
label: t("editor.inlineCode"),
icon: CodeIcon,
disabled,
onClick: () => handleWrapSelection("`"),
@@ -123,28 +125,28 @@ export function MarkdownEditor({
{ key: "separator-2", type: "separator" as const },
{
key: "bulletList",
label: "无序列表",
label: t("editor.bulletList"),
icon: ListIcon,
disabled,
onClick: () => handleInsertLinePrefix("- "),
},
{
key: "orderedList",
label: "有序列表",
label: t("editor.orderedList"),
icon: ListOrderedIcon,
disabled,
onClick: () => handleInsertLinePrefix("1. "),
},
{
key: "blockquote",
label: "引用",
label: t("editor.quote"),
icon: QuoteIcon,
disabled,
onClick: () => handleInsertLinePrefix("> "),
},
{
key: "link",
label: "链接",
label: t("editor.link"),
icon: LinkIcon,
disabled,
onClick: handleInsertLink,
@@ -168,4 +170,3 @@ export function MarkdownEditor({
</div>
)
}
+13 -10
View File
@@ -5,6 +5,7 @@ import { UploadIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { uploadAsset } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
export type ImageInputProps = {
@@ -25,11 +26,13 @@ export function ImageInput({
accept = "image/*",
maxSize = 5 * 1024 * 1024,
prefix,
placeholder = "点击上传图片",
placeholder,
className,
}: ImageInputProps) {
const t = useI18n()
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const resolvedPlaceholder = placeholder ?? t("upload.imagePlaceholder")
function handleClick() {
if (disabled || uploading) {
@@ -50,13 +53,13 @@ export function ImageInput({
}
if (!file.type.startsWith("image/")) {
toast.error("请选择图片文件")
toast.error(t("upload.chooseImage"))
return
}
if (file.size > maxSize) {
const maxSizeMB = (maxSize / 1024 / 1024).toFixed(0)
toast.error(`图片大小不能超过 ${maxSizeMB}MB`)
toast.error(t("upload.imageTooLarge", { maxSize: maxSizeMB }))
return
}
@@ -64,9 +67,9 @@ export function ImageInput({
try {
const result = await uploadAsset(file, prefix)
onChange?.(result.url)
toast.success("图片上传成功")
toast.success(t("upload.imageUploaded"))
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传图片失败")
toast.error(error instanceof Error ? error.message : t("upload.imageUploadFailed"))
} finally {
setUploading(false)
if (fileInputRef.current) {
@@ -103,19 +106,19 @@ export function ImageInput({
}
}}
role="button"
aria-label={value ? "更换图片" : placeholder}
aria-label={value ? t("upload.replaceImage") : resolvedPlaceholder}
>
{value ? (
<>
<img src={value} alt="已上传图片" className="size-full object-cover" />
<img src={value} alt={t("upload.uploadedImage")} className="size-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<span className="text-sm text-white">更换图片</span>
<span className="text-sm text-white">{t("upload.replaceImage")}</span>
</div>
</>
) : (
<div className="flex flex-col items-center gap-1 text-muted-foreground">
<UploadIcon className="size-6" />
<span className="text-xs">{uploading ? "上传中..." : placeholder}</span>
<span className="text-xs">{uploading ? t("upload.uploading") : resolvedPlaceholder}</span>
</div>
)}
{uploading && (
@@ -129,7 +132,7 @@ export function ImageInput({
type="button"
onClick={handleClear}
className="absolute -right-2 -top-2 flex size-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-sm transition-colors hover:bg-destructive/80"
aria-label="删除图片"
aria-label={t("upload.deleteImage")}
>
<XIcon className="size-3" />
</button>
+21 -17
View File
@@ -35,6 +35,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { translateCurrentMessage } from "@/i18n/messages";
import { useI18n } from "@/i18n/provider";
type ImageLightboxItem = {
src: string;
@@ -53,12 +55,12 @@ const ImageLightboxContext = createContext<ImageLightboxContextValue | null>(
export function useImageLightbox(): ImageLightboxContextValue {
const ctx = useContext(ImageLightboxContext);
if (!ctx) {
throw new Error("useImageLightbox 必须在 ImageLightboxProvider 内使用");
throw new Error(translateCurrentMessage("lightbox.providerError"));
}
return ctx;
}
/** 未包裹 Provider 时返回 null,便于渐进接入 */
/** Returns null when no provider is mounted, which keeps adoption incremental. */
export function useImageLightboxOptional(): ImageLightboxContextValue | null {
return useContext(ImageLightboxContext);
}
@@ -100,6 +102,7 @@ function LightboxImageBody({
pinchRef: React.RefObject<ReactZoomPanPinchContentRef | null>;
rotationDeg: number;
}) {
const t = useI18n();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const showOpenTab = canOpenInNewTab(src);
@@ -122,7 +125,7 @@ function LightboxImageBody({
) : null}
{error ? (
<div className="flex min-h-[min(50vh,320px)] flex-col items-center justify-center gap-4 px-6 py-12 text-center text-sm text-white/90">
<p>图片加载失败</p>
<p>{t("lightbox.loadFailed")}</p>
{showOpenTab ? (
<a
href={src}
@@ -130,7 +133,7 @@ function LightboxImageBody({
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
>
在新标签页打开
{t("lightbox.openInNewTab")}
</a>
) : null}
</div>
@@ -152,10 +155,10 @@ function LightboxImageBody({
wrapperClass="!h-full !w-full !max-h-full !max-w-full"
contentClass="!flex !h-full !min-h-0 !w-full !min-w-0 !items-center !justify-center !p-4 sm:!p-6"
>
{/* eslint-disable-next-line @next/next/no-img-element -- 外链与任意尺寸大图预览 */}
{/* eslint-disable-next-line @next/next/no-img-element -- External URLs and arbitrary image sizes need native preview. */}
<img
src={src}
alt={alt || "预览图片"}
alt={alt || t("lightbox.previewImage")}
draggable={false}
style={{ transform: `rotate(${rotationDeg}deg)` }}
className={cn(
@@ -181,7 +184,7 @@ function LightboxImageBody({
);
}
/** 按 src 作为 key 挂载,切换图片时旋转角自动回到 0 */
/** Mounted by src key so rotation resets when switching images. */
function ImageLightboxDialogContent({
src,
alt,
@@ -189,10 +192,11 @@ function ImageLightboxDialogContent({
src: string;
alt?: string;
}) {
const t = useI18n();
const pinchRef = useRef<ReactZoomPanPinchContentRef | null>(null);
const [rotationDeg, setRotationDeg] = useState(0);
const showOpenTab = canOpenInNewTab(src);
const titleText = alt?.trim() || "图片预览";
const titleText = alt?.trim() || t("lightbox.imagePreview");
const rotateLeft = useCallback(() => {
setRotationDeg((d) => (d - 90 + 360) % 360);
@@ -222,7 +226,7 @@ function ImageLightboxDialogContent({
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="放大"
aria-label={t("lightbox.zoomIn")}
onClick={() => pinchRef.current?.zoomIn(0.25)}
>
<ZoomInIcon className="size-4" />
@@ -232,7 +236,7 @@ function ImageLightboxDialogContent({
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="缩小"
aria-label={t("lightbox.zoomOut")}
onClick={() => pinchRef.current?.zoomOut(0.25)}
>
<ZoomOutIcon className="size-4" />
@@ -242,7 +246,7 @@ function ImageLightboxDialogContent({
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="向左旋转"
aria-label={t("lightbox.rotateLeft")}
onClick={rotateLeft}
>
<RotateCcwIcon className="size-4" />
@@ -252,7 +256,7 @@ function ImageLightboxDialogContent({
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="向右旋转"
aria-label={t("lightbox.rotateRight")}
onClick={rotateRight}
>
<RotateCwIcon className="size-4" />
@@ -262,7 +266,7 @@ function ImageLightboxDialogContent({
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="重置缩放、位置与旋转"
aria-label={t("lightbox.reset")}
onClick={() => {
setRotationDeg(0);
pinchRef.current?.resetTransform(200);
@@ -276,7 +280,7 @@ function ImageLightboxDialogContent({
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="在新标签页打开"
aria-label={t("lightbox.openInNewTab")}
onClick={() => {
window.open(src, "_blank", "noopener,noreferrer");
}}
@@ -291,12 +295,12 @@ function ImageLightboxDialogContent({
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="关闭"
aria-label={t("lightbox.close")}
/>
}
>
<XIcon className="size-4" />
<span className="sr-only">关闭</span>
<span className="sr-only">{t("lightbox.close")}</span>
</DialogClose>
</div>
</div>
@@ -309,7 +313,7 @@ function ImageLightboxDialogContent({
/>
</div>
<p className="sr-only">
使用滚轮或双指缩放,按住拖拽可平移图片;工具栏可向左或向右旋转。
{t("lightbox.help")}
</p>
</DialogPrimitive.Popup>
</DialogPortal>
+7 -5
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo } from "react"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type JsonCodeEditorProps = {
value: string
@@ -12,7 +13,7 @@ type JsonCodeEditorProps = {
className?: string
}
function validateJson(value: string) {
function validateJson(value: string, fallbackMessage: string) {
const text = value.trim()
if (!text) {
return null
@@ -21,7 +22,7 @@ function validateJson(value: string) {
JSON.parse(text)
return null
} catch (error) {
return error instanceof Error ? error.message : "JSON 格式不合法"
return error instanceof Error ? error.message : fallbackMessage
}
}
@@ -32,7 +33,8 @@ export function JsonCodeEditor({
disabled = false,
className,
}: JsonCodeEditorProps) {
const error = useMemo(() => validateJson(value), [value])
const t = useI18n()
const error = useMemo(() => validateJson(value, t("json.invalid")), [t, value])
const lineCount = Math.max(1, value.split("\n").length)
useEffect(() => {
@@ -49,7 +51,7 @@ export function JsonCodeEditor({
error ? "text-rose-300" : "text-emerald-300"
)}
>
{error ? "格式错误" : "格式正确"}
{error ? t("json.invalidLabel") : t("json.valid")}
</span>
</div>
<div className="flex min-h-52">
@@ -68,7 +70,7 @@ export function JsonCodeEditor({
/>
</div>
<div className="border-t border-slate-800 px-3 py-2 text-xs text-slate-400">
{error || "输入合法 JSON 后即可测试工具调用。"}
{error || t("json.readyHint")}
</div>
</div>
)

Some files were not shown because too many files have changed in this diff Show More