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
+24 -22
View File
@@ -50,6 +50,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
const windowActionButtonClass =
"size-8 rounded-md border-0 bg-transparent text-muted-foreground shadow-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-primary/20 dark:hover:bg-white/10"
@@ -119,6 +120,7 @@ function isEmbeddedInHost() {
}
export function KefuChatShell() {
const t = useI18n()
useKefuSystemTheme()
const messageListRef = useRef<KefuMessageListHandle | null>(null)
@@ -271,7 +273,7 @@ export function KefuChatShell() {
window.location.replace(getStandaloneClosedUrl())
}
} catch (closeError) {
window.alert(closeError instanceof Error ? closeError.message : "关闭会话失败")
window.alert(closeError instanceof Error ? closeError.message : t("kefu.closeConversationFailed"))
} finally {
setIsClosingConversation(false)
}
@@ -324,8 +326,8 @@ export function KefuChatShell() {
{!isEmbedded && status !== "connected" ? (
<WindowActionButton
onClick={retry}
aria-label="重新连接"
title="重新连接"
aria-label={t("kefu.retry")}
title={t("kefu.retry")}
>
<RotateCwIcon className="size-4" />
</WindowActionButton>
@@ -333,18 +335,18 @@ export function KefuChatShell() {
{isEmbedded ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<WindowActionButton aria-label="更多操作" title="更多操作" />}
render={<WindowActionButton aria-label={t("kefu.moreActions")} title={t("kefu.moreActions")} />}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={retry}>
<RotateCwIcon className="size-4" />
{t("kefu.retry")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleMinimize}>
<MinusIcon className="size-4" />
{t("kefu.minimize")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggleMaximize}>
{isMaximized ? (
@@ -352,22 +354,22 @@ export function KefuChatShell() {
) : (
<Maximize2Icon className="size-4" />
)}
{isMaximized ? "取消最大化" : "最大化"}
{isMaximized ? t("kefu.restoreWindow") : t("kefu.maximize")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setIsCloseDialogOpen(true)}
>
<XIcon className="size-4" />
{t("kefu.closeWindow")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<WindowActionButton
onClick={() => setIsCloseDialogOpen(true)}
aria-label="关闭聊天窗口"
title="关闭聊天窗口"
aria-label={t("kefu.closeChatWindow")}
title={t("kefu.closeChatWindow")}
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
>
<XIcon className="size-4" />
@@ -381,8 +383,8 @@ export function KefuChatShell() {
<div className="flex items-center gap-0.5 rounded-lg bg-background/55 p-0.5 shadow-sm ring-1 ring-border/70 dark:bg-background/25 dark:ring-white/10">
<WindowActionButton
onClick={retry}
aria-label="重新连接"
title="重新连接"
aria-label={t("kefu.retry")}
title={t("kefu.retry")}
>
<RotateCwIcon className="size-4" />
</WindowActionButton>
@@ -390,15 +392,15 @@ export function KefuChatShell() {
<>
<WindowActionButton
onClick={handleMinimize}
aria-label="收起聊天窗口"
title="收起聊天窗口"
aria-label={t("kefu.minimize")}
title={t("kefu.minimize")}
>
<MinusIcon className="size-4" />
</WindowActionButton>
<WindowActionButton
onClick={handleToggleMaximize}
aria-label={isMaximized ? "取消最大化" : "最大化聊天窗口"}
title={isMaximized ? "取消最大化" : "最大化聊天窗口"}
aria-label={isMaximized ? t("kefu.restoreWindow") : t("kefu.maximizeWindow")}
title={isMaximized ? t("kefu.restoreWindow") : t("kefu.maximizeWindow")}
>
{isMaximized ? (
<Minimize2Icon className="size-4" />
@@ -410,8 +412,8 @@ export function KefuChatShell() {
) : null}
<WindowActionButton
onClick={() => setIsCloseDialogOpen(true)}
aria-label="关闭聊天窗口"
title="关闭聊天窗口"
aria-label={t("kefu.closeChatWindow")}
title={t("kefu.closeChatWindow")}
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
>
<XIcon className="size-4" />
@@ -457,9 +459,9 @@ export function KefuChatShell() {
>
<DialogContent className="max-w-[320px]" showCloseButton={!isClosingConversation}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("kefu.closeDialogTitle")}</DialogTitle>
<DialogDescription className="text-xs leading-5">
{t("kefu.closeDialogDescription")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -469,7 +471,7 @@ export function KefuChatShell() {
disabled={isClosingConversation}
onClick={() => setIsCloseDialogOpen(false)}
>
{t("kefu.continueConversation")}
</Button>
<Button
type="button"
@@ -477,7 +479,7 @@ export function KefuChatShell() {
disabled={isClosingConversation}
onClick={() => void confirmCloseConversation()}
>
{isClosingConversation ? "结束中..." : "确认结束"}
{isClosingConversation ? t("kefu.closing") : t("kefu.confirmClose")}
</Button>
</DialogFooter>
</DialogContent>
+3 -7
View File
@@ -2,18 +2,14 @@
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type KefuConnectionStatusProps = {
status: "connecting" | "connected" | "disconnected"
}
const statusText: Record<KefuConnectionStatusProps["status"], string> = {
connecting: "连接中",
connected: "在线服务",
disconnected: "连接已断开",
}
export function KefuConnectionStatus({ status }: KefuConnectionStatusProps) {
const t = useI18n()
const toneClass =
status === "connected"
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300"
@@ -36,7 +32,7 @@ export function KefuConnectionStatus({ status }: KefuConnectionStatusProps) {
: "bg-muted-foreground shadow-[0_0_0_4px_rgba(148,163,184,0.14)]"
)}
/>
<span>{statusText[status]}</span>
<span>{t(`kefu.${status}`)}</span>
</Badge>
)
}
+19 -10
View File
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button"
import type { ImMessage } from "@/lib/api/im"
import { renderIMMessageHTML } from "@/lib/im-message"
import { cn, formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type KefuMessageListProps = {
messages?: ImMessage[] | null
@@ -44,9 +45,12 @@ function getDayKey(value?: string) {
).padStart(2, "0")}`
}
function getTimelineLabel(value?: string) {
function getTimelineLabel(
value: string | undefined,
t: (key: string, values?: Record<string, string | number>) => string
) {
if (!value) {
return "刚刚"
return t("kefu.justNow")
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
@@ -58,7 +62,7 @@ function getTimelineLabel(value?: string) {
date.getMinutes()
).padStart(2, "0")}`
if (currentDayKey === todayDayKey) {
return `今天 ${timeText}`
return t("kefu.todayAt", { time: timeText })
}
return `${currentDayKey} ${timeText}`
}
@@ -74,6 +78,7 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
},
ref
) {
const t = useI18n()
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const frameRef = useRef<number | null>(null)
@@ -227,14 +232,14 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
onClick={() => void handleLoadOlder()}
className="h-7 rounded-full bg-background/90 text-xs text-muted-foreground shadow-sm hover:bg-background hover:text-sky-700 dark:hover:text-sky-400"
>
{loadingOlder ? "加载中…" : "加载更早的消息"}
{loadingOlder ? t("kefu.loadingOlder") : t("kefu.loadOlder")}
</Button>
</div>
) : null}
{safeMessages.length === 0 ? (
<div className="flex min-h-32 items-center justify-center px-3 py-6 text-center text-sm leading-6 text-muted-foreground">
{t("kefu.emptyPrompt")}
</div>
) : null}
@@ -250,6 +255,7 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
message={message}
showTimeline={showTimeline}
onImageSettled={handleImageSettled}
timelineLabel={getTimelineLabel(message.sentAt, t)}
/>
)
})}
@@ -263,13 +269,15 @@ type MessageItemProps = {
message: ImMessage
showTimeline: boolean
onImageSettled: () => void
timelineLabel: string
}
const MessageItem = memo(
function MessageItem({ message, showTimeline, onImageSettled }: MessageItemProps) {
function MessageItem({ message, showTimeline, onImageSettled, timelineLabel }: MessageItemProps) {
const t = useI18n()
const { open } = useImageLightbox()
const isCustomer = message.senderType === "customer"
const senderName = isCustomer ? "我" : message.senderName?.trim() || "客服"
const senderName = isCustomer ? t("kefu.customerSelf") : message.senderName?.trim() || t("kefu.agentLabel")
const avatarSrc =
!isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined
const htmlContent = renderIMMessageHTML(message)
@@ -283,7 +291,7 @@ const MessageItem = memo(
variant="outline"
className="border-border bg-background/85 text-[11px] font-medium text-muted-foreground shadow-sm"
>
{getTimelineLabel(message.sentAt)}
{timelineLabel}
</Badge>
</div>
) : null}
@@ -293,7 +301,7 @@ const MessageItem = memo(
<Avatar className="mt-5">
{avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null}
<AvatarFallback className="bg-muted text-muted-foreground">
{fallbackName || "客"}
{fallbackName || t("kefu.customerFallback")}
</AvatarFallback>
</Avatar>
) : null}
@@ -308,7 +316,7 @@ const MessageItem = memo(
<span className="font-medium">{senderName}</span>
<span>{formatDateTime(message.sentAt)}</span>
{isCustomer ? (
<span>{message.agentRead ? "客服已读" : "客服未读"}</span>
<span>{message.agentRead ? t("kefu.agentRead") : t("kefu.agentUnread")}</span>
) : null}
</div>
<div
@@ -338,6 +346,7 @@ const MessageItem = memo(
(prevProps, nextProps) =>
isSameMessageItemRender(prevProps.message, nextProps.message) &&
prevProps.showTimeline === nextProps.showTimeline &&
prevProps.timelineLabel === nextProps.timelineLabel &&
prevProps.onImageSettled === nextProps.onImageSettled
)
+42 -37
View File
@@ -5,6 +5,7 @@ import { CheckIcon, CopyIcon } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import type { CSAgentConfig } from "@/lib/sdk/config-types"
import { useI18n } from "@/i18n/provider"
const STORAGE_KEY = "cs-agent-web-widget-test-config"
const DEFAULT_JWT_TTL_MINUTES = "30"
@@ -24,7 +25,7 @@ type WidgetDemoConfig = CSAgentConfig & {
jwtTtlMinutes?: string
}
function getDefaultConfig(): WidgetDemoConfig {
function getDefaultConfig(defaultName: string): WidgetDemoConfig {
if (typeof window === "undefined") {
return INITIAL_CONFIG
}
@@ -42,7 +43,7 @@ function getDefaultConfig(): WidgetDemoConfig {
authMode: (query.get("authMode") as AuthMode | null) ?? savedConfig.authMode ?? "guest",
jwtSecret: savedConfig.jwtSecret ?? "",
jwtUserId: query.get("userId") ?? savedConfig.jwtUserId ?? "demo-user-001",
jwtName: query.get("name") ?? savedConfig.jwtName ?? "测试用户",
jwtName: query.get("name") ?? savedConfig.jwtName ?? defaultName,
jwtTtlMinutes: savedConfig.jwtTtlMinutes ?? DEFAULT_JWT_TTL_MINUTES,
}
}
@@ -83,28 +84,28 @@ function buildWidgetConfig(config: WidgetDemoConfig): CSAgentConfig {
apiBaseUrl: "",
}
if (config.authMode === "jwt") {
nextConfig.getUserToken = () => signUserToken(config)
nextConfig.getUserToken = undefined
}
return nextConfig
}
async function signUserToken(config: WidgetDemoConfig) {
async function signUserToken(config: WidgetDemoConfig, t: (key: string) => string) {
const userId = (config.jwtUserId || "").trim()
const name = (config.jwtName || "").trim()
const secret = (config.jwtSecret || "").trim()
const ttl = Number(config.jwtTtlMinutes || DEFAULT_JWT_TTL_MINUTES)
if (!userId) {
throw new Error("请填写 userId")
throw new Error(t("widgetDemo.missingUserId"))
}
if (!name) {
throw new Error("请填写用户名称")
throw new Error(t("widgetDemo.missingName"))
}
if (!secret) {
throw new Error("请填写 JWT Secret")
throw new Error(t("widgetDemo.missingSecret"))
}
if (!Number.isFinite(ttl) || ttl <= 0) {
throw new Error("有效期必须大于 0")
throw new Error(t("widgetDemo.invalidTtl"))
}
return new SignJWT({ userId, name })
@@ -115,15 +116,16 @@ async function signUserToken(config: WidgetDemoConfig) {
}
export function KefuWidgetDemo() {
const t = useI18n()
const [config, setConfig] = useState<WidgetDemoConfig>({
...INITIAL_CONFIG,
authMode: "guest",
jwtSecret: "",
jwtUserId: "demo-user-001",
jwtName: "测试用户",
jwtName: t("widgetDemo.defaultName"),
jwtTtlMinutes: DEFAULT_JWT_TTL_MINUTES,
})
const [status, setStatus] = useState("请填写 channelId")
const [status, setStatus] = useState(t("widgetDemo.missingChannel"))
const [origin, setOrigin] = useState("")
const [generatedToken, setGeneratedToken] = useState("")
const [latestDirectChatUrl, setLatestDirectChatUrl] = useState("")
@@ -139,6 +141,9 @@ export function KefuWidgetDemo() {
getUserToken: undefined,
}
const nextConfig = buildWidgetConfig(cleanConfig)
if (cleanConfig.authMode === "jwt") {
nextConfig.getUserToken = () => signUserToken(cleanConfig, t)
}
setConfig(cleanConfig)
setGeneratedToken("")
setLatestDirectChatUrl("")
@@ -146,30 +151,30 @@ export function KefuWidgetDemo() {
if (!nextConfig.channelId) {
removeMountedWidget()
setStatus("请填写 channelId")
setStatus(t("widgetDemo.missingChannel"))
return
}
injectWidget(nextConfig)
setStatus(
cleanConfig.authMode === "jwt"
? "Widget 已挂载:JWT 用户模式"
: "Widget 已挂载:访客模式"
? t("widgetDemo.mountedJwt")
: t("widgetDemo.mountedGuest")
)
}
useEffect(() => {
const timer = window.setTimeout(() => {
const initialConfig = getDefaultConfig()
const initialConfig = getDefaultConfig(t("widgetDemo.defaultName"))
setOrigin(window.location.origin)
setConfig(initialConfig)
setStatus(initialConfig.channelId ? "Widget 已挂载" : "请填写 channelId")
setStatus(initialConfig.channelId ? t("widgetDemo.mounted") : t("widgetDemo.missingChannel"))
if (initialConfig.channelId) {
void mountWidget(initialConfig).catch((error) => {
removeMountedWidget()
setGeneratedToken("")
setStatus(error instanceof Error ? error.message : "挂载 Widget 失败")
setStatus(error instanceof Error ? error.message : t("widgetDemo.mountFailed"))
})
}
}, 0)
@@ -178,7 +183,7 @@ export function KefuWidgetDemo() {
window.clearTimeout(timer)
removeMountedWidget()
}
}, [])
}, [t])
const snippet = useMemo(() => {
const scriptSrc = origin
@@ -215,7 +220,7 @@ ${configLines.join(",\n")}
} catch (error) {
removeMountedWidget()
setGeneratedToken("")
setStatus(error instanceof Error ? error.message : "挂载 Widget 失败")
setStatus(error instanceof Error ? error.message : t("widgetDemo.mountFailed"))
}
}
@@ -233,7 +238,7 @@ ${configLines.join(",\n")}
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch (error) {
setStatus(error instanceof Error ? error.message : "生成客服链接失败")
setStatus(error instanceof Error ? error.message : t("widgetDemo.linkFailed"))
}
}
@@ -249,7 +254,7 @@ ${configLines.join(",\n")}
}
window.open(url, "_blank", "noopener,noreferrer")
} catch (error) {
setStatus(error instanceof Error ? error.message : "生成客服链接失败")
setStatus(error instanceof Error ? error.message : t("widgetDemo.linkFailed"))
}
}
@@ -266,7 +271,7 @@ ${configLines.join(",\n")}
<main className="min-h-svh bg-slate-50 px-6 py-8 text-slate-950">
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<div className="text-base font-semibold">Widget </div>
<div className="text-base font-semibold">{t("widgetDemo.title")}</div>
<div className="mt-1 text-sm text-slate-500">{status}</div>
<div className="mt-5 grid gap-3">
@@ -276,12 +281,12 @@ ${configLines.join(",\n")}
onChange={(value) => updateField("channelId", value)}
/>
<SegmentedControl
label="鉴权模式"
label={t("widgetDemo.authMode")}
value={config.authMode || "guest"}
onChange={(value) => updateField("authMode", value)}
options={[
{ label: "访客", value: "guest" },
{ label: "JWT 用户", value: "jwt" },
{ label: t("widgetDemo.guest"), value: "guest" },
{ label: t("widgetDemo.jwtUser"), value: "jwt" },
]}
/>
{config.authMode === "jwt" ? (
@@ -303,7 +308,7 @@ ${configLines.join(",\n")}
type="password"
/>
<TextField
label="有效期(分钟)"
label={t("widgetDemo.ttlMinutes")}
value={config.jwtTtlMinutes}
onChange={(value) => updateField("jwtTtlMinutes", value)}
type="number"
@@ -318,26 +323,26 @@ ${configLines.join(",\n")}
onClick={() => void handleMount()}
className="rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white"
>
{t("widgetDemo.mount")}
</button>
<button
type="button"
onClick={() => {
removeMountedWidget()
setStatus("Widget 已卸载")
setStatus(t("widgetDemo.unmounted"))
}}
className="rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-medium"
>
{t("widgetDemo.unmount")}
</button>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<div className="text-base font-semibold"></div>
<div className="text-base font-semibold">{t("widgetDemo.snippetTitle")}</div>
{config.authMode === "jwt" ? (
<div className="mt-2 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800">
userToken
{t("widgetDemo.jwtNotice")}
</div>
) : null}
<div className="relative mt-4">
@@ -345,8 +350,8 @@ ${configLines.join(",\n")}
type="button"
onClick={() => void handleCopySnippet()}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-md border border-white/10 bg-white/10 text-slate-200 transition hover:bg-white/20 hover:text-white"
aria-label={snippetCopied ? "已复制接入代码" : "复制接入代码"}
title={snippetCopied ? "已复制" : "复制代码"}
aria-label={snippetCopied ? t("widgetDemo.copiedSnippet") : t("widgetDemo.copySnippet")}
title={snippetCopied ? t("widgetDemo.copied") : t("widgetDemo.copyCode")}
>
{snippetCopied ? (
<CheckIcon className="size-4" />
@@ -359,11 +364,11 @@ ${configLines.join(",\n")}
</pre>
</div>
<div className="mt-5">
<div className="text-sm font-medium text-slate-700">访</div>
<div className="text-sm font-medium text-slate-700">{t("widgetDemo.directChat")}</div>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<input
readOnly
value={latestDirectChatUrl || "点击复制或新窗口打开时生成最新链接"}
value={latestDirectChatUrl || t("widgetDemo.directChatPlaceholder")}
className="h-9 min-w-0 flex-1 rounded-md border border-slate-200 px-3 font-mono text-xs outline-none"
/>
<div className="flex gap-2">
@@ -373,7 +378,7 @@ ${configLines.join(",\n")}
onClick={() => void handleCopyDirectUrl()}
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
{copied ? "已复制" : "复制"}
{copied ? t("widgetDemo.copied") : t("widgetDemo.copy")}
</button>
<button
type="button"
@@ -381,14 +386,14 @@ ${configLines.join(",\n")}
onClick={() => void handleOpenDirectChat()}
className="rounded-md bg-slate-950 px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{t("widgetDemo.openNewWindow")}
</button>
</div>
</div>
</div>
{generatedToken ? (
<div className="mt-4">
<div className="text-sm font-medium text-slate-700"> userToken</div>
<div className="text-sm font-medium text-slate-700">{t("widgetDemo.currentToken")}</div>
<textarea
readOnly
value={generatedToken}