refactor: support i18n
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CSSProperties } from "react"
|
||||
import JsonView from "@uiw/react-json-view"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type JsonTreeViewerProps = {
|
||||
value: unknown
|
||||
@@ -34,10 +35,12 @@ const viewerTheme = {
|
||||
|
||||
export function JsonTreeViewer({
|
||||
value,
|
||||
emptyText = "暂无数据",
|
||||
emptyText,
|
||||
className,
|
||||
collapsed = 2,
|
||||
}: JsonTreeViewerProps) {
|
||||
const t = useI18n()
|
||||
|
||||
if (value == null || value === "") {
|
||||
return (
|
||||
<div
|
||||
@@ -46,7 +49,7 @@ export function JsonTreeViewer({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{emptyText}
|
||||
{emptyText ?? t("json.empty")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type JsonViewerProps = {
|
||||
value: unknown
|
||||
@@ -48,9 +49,10 @@ function highlightJson(value: string) {
|
||||
|
||||
export function JsonViewer({
|
||||
value,
|
||||
emptyText = "暂无数据",
|
||||
emptyText,
|
||||
className,
|
||||
}: JsonViewerProps) {
|
||||
const t = useI18n()
|
||||
const formatted = formatJson(value)
|
||||
if (!formatted) {
|
||||
return (
|
||||
@@ -60,7 +62,7 @@ export function JsonViewer({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{emptyText}
|
||||
{emptyText ?? t("json.empty")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type ListPaginationProps = {
|
||||
page: number
|
||||
@@ -30,6 +31,7 @@ export function ListPagination({
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: ListPaginationProps) {
|
||||
const t = useI18n()
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit))
|
||||
const canGoPreviousPage = page > 1
|
||||
const canGoNextPage = page < totalPages
|
||||
@@ -49,9 +51,9 @@ export function ListPagination({
|
||||
<div className="flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
第 {page} 页 / 共 {totalPages} 页
|
||||
{t("pagination.pageSummary", { page, totalPages })}
|
||||
</span>
|
||||
<span>共 {total} 条记录</span>
|
||||
<span>{t("pagination.total", { total })}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={String(limit)} onValueChange={handleLimitChange}>
|
||||
@@ -61,7 +63,7 @@ export function ListPagination({
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={String(pageSize)}>
|
||||
每页 {pageSize} 条
|
||||
{t("pagination.pageSize", { pageSize })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -72,14 +74,14 @@ export function ListPagination({
|
||||
disabled={loading || !canGoPreviousPage}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
上一页
|
||||
{t("pagination.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={loading || !canGoNextPage}
|
||||
>
|
||||
下一页
|
||||
{t("pagination.next")}
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { LanguagesIcon } from "lucide-react"
|
||||
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { SUPPORTED_LOCALES, type AppLocale } from "@/i18n/config"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const t = useI18n()
|
||||
const { locale, setLocale } = useAppLocale()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="sm" />}
|
||||
aria-label={t("common.language")}
|
||||
>
|
||||
<LanguagesIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
value={locale}
|
||||
onValueChange={(value) => setLocale(value as AppLocale)}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((option) => (
|
||||
<DropdownMenuRadioItem key={option} value={option}>
|
||||
{t(`locale.${option}`)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { toast } from "sonner"
|
||||
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { loginWithPassword } from "@/lib/api/auth"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { session } = useAuth()
|
||||
@@ -72,12 +74,12 @@ export function LoginForm({
|
||||
|
||||
try {
|
||||
await loginWithPassword({ username, password })
|
||||
toast.success("登录成功,正在进入系统")
|
||||
toast.success(t("auth.loginSuccess"))
|
||||
startTransition(() => {
|
||||
router.push(redirectPath)
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "登录失败")
|
||||
toast.error(error instanceof Error ? error.message : t("auth.loginFailed"))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
@@ -92,36 +94,36 @@ export function LoginForm({
|
||||
<FieldGroup>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<span className="mx-auto inline-flex rounded-full border border-amber-300/60 bg-amber-50 px-3 py-1 text-[11px] font-medium tracking-[0.22em] text-amber-900 uppercase">
|
||||
贝壳AI
|
||||
{t("auth.badge")}
|
||||
</span>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">欢迎使用贝壳 AI 客服平台</h1>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{t("auth.welcome")}</h1>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">用户名</FieldLabel>
|
||||
<FieldLabel htmlFor="username">{t("auth.username")}</FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="用户名或邮箱"
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">密码</FieldLabel>
|
||||
<FieldLabel htmlFor="password">{t("auth.password")}</FieldLabel>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "登录中..." : "登录"}
|
||||
{isPending ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</Button>
|
||||
</Field>
|
||||
<Field>
|
||||
@@ -135,7 +137,7 @@ export function LoginForm({
|
||||
}}
|
||||
>
|
||||
<Image src="/images/wxwork.svg" alt="" width={16} height={16} className="size-4 shrink-0" />
|
||||
企业微信登录
|
||||
{t("auth.wxworkSignIn")}
|
||||
</Button>
|
||||
</Field>
|
||||
<Field>
|
||||
@@ -148,7 +150,7 @@ export function LoginForm({
|
||||
}}
|
||||
>
|
||||
<KeyRoundIcon className="size-4 shrink-0" />
|
||||
OIDC 登录
|
||||
{t("auth.oidcSignIn")}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { MoreHorizontalIcon, FolderIcon, ShareIcon, Trash2Icon } from "lucide-react"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export function NavDocuments({
|
||||
items,
|
||||
@@ -32,9 +33,10 @@ export function NavDocuments({
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const { isMobile } = useSidebar()
|
||||
const t = useI18n()
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>业务模块</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t("scaffold.businessModules")}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
@@ -66,18 +68,18 @@ export function NavDocuments({
|
||||
<DropdownMenuItem>
|
||||
<FolderIcon
|
||||
/>
|
||||
<span>打开</span>
|
||||
<span>{t("scaffold.open")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<ShareIcon
|
||||
/>
|
||||
<span>分享</span>
|
||||
<span>{t("scaffold.share")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon
|
||||
/>
|
||||
<span>删除</span>
|
||||
<span>{t("scaffold.delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -86,7 +88,7 @@ export function NavDocuments({
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<MoreHorizontalIcon className="text-sidebar-foreground/70" />
|
||||
<span>更多</span>
|
||||
<span>{t("scaffold.more")}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
||||
import { useNotifications } from "@/components/notification-provider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -43,6 +44,7 @@ export function NavUser({
|
||||
avatar: string
|
||||
}
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const { signOut } = useAuth()
|
||||
const { unreadCount } = useNotifications()
|
||||
const { isMobile } = useSidebar()
|
||||
@@ -102,7 +104,7 @@ export function NavUser({
|
||||
className="gap-2"
|
||||
>
|
||||
<BellIcon />
|
||||
<span className="flex-1">通知中心</span>
|
||||
<span className="flex-1">{t("nav.notifications")}</span>
|
||||
{unreadCount > 0 ? (
|
||||
<Badge className="h-5 min-w-5 px-1.5">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
@@ -115,7 +117,7 @@ export function NavUser({
|
||||
}}
|
||||
>
|
||||
<KeyRoundIcon />
|
||||
修改密码
|
||||
{t("nav.changePassword")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -125,7 +127,7 @@ export function NavUser({
|
||||
}}
|
||||
>
|
||||
<LogOutIcon />
|
||||
退出登录
|
||||
{t("nav.signOut")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
createRealtimeConnectionManager,
|
||||
type RealtimeConnectionStatus,
|
||||
} from "@/lib/realtime-connection"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { localizeNotificationItem } from "@/lib/notification-i18n"
|
||||
|
||||
type NotificationRealtimeEnvelope = {
|
||||
eventId?: string
|
||||
@@ -43,6 +45,8 @@ type NotificationContextValue = {
|
||||
const NotificationContext = createContext<NotificationContextValue | null>(null)
|
||||
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const router = useRouter()
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [realtimeStatus, setRealtimeStatus] =
|
||||
@@ -106,14 +110,15 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
if (!notification || notification.recipientUserId !== currentUserIdRef.current) {
|
||||
return
|
||||
}
|
||||
const localizedNotification = localizeNotificationItem(notification, locale)
|
||||
setUnreadCount((current) => current + 1)
|
||||
toast(notification.title || "新通知", {
|
||||
description: notification.content,
|
||||
toast(localizedNotification.title || t("notification.new"), {
|
||||
description: localizedNotification.content,
|
||||
action: {
|
||||
label: "查看",
|
||||
label: t("notification.view"),
|
||||
onClick: () => {
|
||||
void markReadAndNavigate(notification).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "打开通知失败")
|
||||
void markReadAndNavigate(localizedNotification).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("notification.openFailed"))
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -123,7 +128,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
},
|
||||
onConnectError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "连接通知服务失败")
|
||||
toast.error(error instanceof Error ? error.message : t("notification.connectFailed"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -131,7 +136,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
realtime.disconnect()
|
||||
}
|
||||
}, [markReadAndNavigate, refreshUnreadCount])
|
||||
}, [locale, markReadAndNavigate, refreshUnreadCount, t])
|
||||
|
||||
const value = useMemo<NotificationContextValue>(
|
||||
() => ({
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export type ComboboxOption = {
|
||||
value: string
|
||||
@@ -39,12 +40,13 @@ export function OptionCombobox({
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
searchPlaceholder = "请输入关键字搜索",
|
||||
emptyText = "没有可选项",
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
disabled = false,
|
||||
onChange,
|
||||
renderOptionAction,
|
||||
}: OptionComboboxProps) {
|
||||
const t = useI18n()
|
||||
const selectedLabel =
|
||||
options.find((option) => option.value === value)?.label ?? placeholder
|
||||
|
||||
@@ -65,9 +67,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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { DropletsIcon, PaletteIcon } from "lucide-react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,22 +20,22 @@ const DEFAULT_PALETTE: PaletteMode = "green"
|
||||
|
||||
const paletteOptions: Array<{
|
||||
value: PaletteMode
|
||||
label: string
|
||||
labelKey: string
|
||||
swatch: string
|
||||
}> = [
|
||||
{
|
||||
value: "green",
|
||||
label: "温润服务绿",
|
||||
labelKey: "palette.green",
|
||||
swatch: "bg-teal-700",
|
||||
},
|
||||
{
|
||||
value: "gray",
|
||||
label: "中性精密灰",
|
||||
labelKey: "palette.gray",
|
||||
swatch: "bg-slate-500",
|
||||
},
|
||||
{
|
||||
value: "blue",
|
||||
label: "清透科技蓝",
|
||||
labelKey: "palette.blue",
|
||||
swatch: "bg-blue-600",
|
||||
},
|
||||
]
|
||||
@@ -56,6 +57,7 @@ function applyPalette(value: PaletteMode) {
|
||||
}
|
||||
|
||||
export function PaletteToggle() {
|
||||
const t = useI18n()
|
||||
const [palette, setPalette] = useState<PaletteMode>(DEFAULT_PALETTE)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,7 +79,7 @@ export function PaletteToggle() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="sm" />}
|
||||
aria-label="切换主题色"
|
||||
aria-label={t("palette.toggle")}
|
||||
>
|
||||
<ActiveIcon />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -86,7 +88,7 @@ export function PaletteToggle() {
|
||||
{paletteOptions.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<span className={`size-2.5 rounded-full ${option.swatch}`} />
|
||||
<span className="flex-1">{option.label}</span>
|
||||
<span className="flex-1">{t(option.labelKey)}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Maximize2Icon, Minimize2Icon, XIcon } from "lucide-react";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
const dialogSizeClassName = {
|
||||
sm: "max-w-md sm:max-w-md",
|
||||
@@ -61,6 +62,7 @@ function ProjectDialog({
|
||||
defaultFullscreen = false,
|
||||
bodyScrollable = true,
|
||||
}: ProjectDialogProps) {
|
||||
const t = useI18n();
|
||||
const [fullscreen, setFullscreen] = useState(defaultFullscreen);
|
||||
|
||||
function handleOpenChange(nextOpen: boolean, eventDetails: unknown) {
|
||||
@@ -129,7 +131,7 @@ function ProjectDialog({
|
||||
>
|
||||
{fullscreen ? <Minimize2Icon /> : <Maximize2Icon />}
|
||||
<span className="sr-only">
|
||||
{fullscreen ? "退出全屏" : "全屏显示"}
|
||||
{fullscreen ? t("common.exitFullscreen") : t("common.fullscreen")}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -138,7 +140,7 @@ function ProjectDialog({
|
||||
render={<Button type="button" variant="ghost" size="icon-sm" />}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">关闭</span>
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogClose>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
export type RealtimeConnectionStatusValue =
|
||||
| "connecting"
|
||||
@@ -12,22 +13,23 @@ type RealtimeConnectionStatusProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const statusText: Record<RealtimeConnectionStatusValue, string> = {
|
||||
connecting: "实时通道连接中",
|
||||
connected: "实时通道已连接",
|
||||
disconnected: "实时通道已断开",
|
||||
const statusTextKey: Record<RealtimeConnectionStatusValue, string> = {
|
||||
connecting: "realtime.connecting",
|
||||
connected: "realtime.connected",
|
||||
disconnected: "realtime.disconnected",
|
||||
};
|
||||
|
||||
const compactStatusText: Record<RealtimeConnectionStatusValue, string> = {
|
||||
connecting: "平台实时:连接中",
|
||||
connected: "平台实时:在线",
|
||||
disconnected: "平台实时:已断开",
|
||||
const compactStatusTextKey: Record<RealtimeConnectionStatusValue, string> = {
|
||||
connecting: "realtime.compactConnecting",
|
||||
connected: "realtime.compactConnected",
|
||||
disconnected: "realtime.compactDisconnected",
|
||||
};
|
||||
|
||||
export function RealtimeConnectionStatus({
|
||||
status,
|
||||
compact = false,
|
||||
}: RealtimeConnectionStatusProps) {
|
||||
const t = useI18n();
|
||||
const toneClass =
|
||||
status === "connected"
|
||||
? "border-emerald-200/80 bg-emerald-50 text-emerald-700"
|
||||
@@ -55,9 +57,7 @@ export function RealtimeConnectionStatus({
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
{compact
|
||||
? compactStatusText[status]
|
||||
: statusText[status]}
|
||||
{t(compact ? compactStatusTextKey[status] : statusTextKey[status])}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { TrendingUpIcon, TrendingDownIcon } from "lucide-react"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export function SectionCards() {
|
||||
const t = useI18n()
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4 dark:*:data-[slot=card]:bg-card">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>后台用户</CardDescription>
|
||||
<CardDescription>{t("scaffold.adminUsers")}</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
12
|
||||
</CardTitle>
|
||||
@@ -30,40 +32,40 @@ export function SectionCards() {
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
本周新增管理员{" "}
|
||||
{t("scaffold.newAdminsThisWeek")}{" "}
|
||||
<TrendingUpIcon className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
基础账号体系已可承接真实接口
|
||||
{t("scaffold.accountSystemReady")}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>权限点</CardDescription>
|
||||
<CardDescription>{t("scaffold.permissions")}</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
26
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUpIcon />
|
||||
已初始化
|
||||
{t("scaffold.initialized")}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
菜单与 API 权限待接入{" "}
|
||||
{t("scaffold.permissionsPending")}{" "}
|
||||
<TrendingUpIcon className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
可继续对接后端权限模型
|
||||
{t("scaffold.permissionModelReady")}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>知识库任务</CardDescription>
|
||||
<CardDescription>{t("scaffold.knowledgeTasks")}</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
8
|
||||
</CardTitle>
|
||||
@@ -71,37 +73,37 @@ export function SectionCards() {
|
||||
<Badge variant="outline">
|
||||
<TrendingUpIcon
|
||||
/>
|
||||
运行中
|
||||
{t("scaffold.running")}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
预留 RAG 接入位{" "}
|
||||
{t("scaffold.ragReserved")}{" "}
|
||||
<TrendingUpIcon className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">支持文档导入与索引流程</div>
|
||||
<div className="text-muted-foreground">{t("scaffold.docIndexSupport")}</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>渠道接入</CardDescription>
|
||||
<CardDescription>{t("scaffold.channelAccess")}</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
3
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingDownIcon />
|
||||
待配置
|
||||
{t("scaffold.pendingConfig")}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
企微、钉钉、IM 入口{" "}
|
||||
{t("scaffold.channelEntrances")}{" "}
|
||||
<TrendingDownIcon className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">等待真实连接参数接入</div>
|
||||
<div className="text-muted-foreground">{t("scaffold.waitingRealConfig")}</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { LocaleSwitcher } from "@/components/locale-switcher"
|
||||
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
||||
import { getPageTitle } from "@/lib/navigation"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { getPageTitleKey } from "@/lib/navigation"
|
||||
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
|
||||
import { PaletteToggle } from "@/components/palette-toggle"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
@@ -20,9 +22,10 @@ import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"
|
||||
const SIDEBAR_STORAGE_KEY = "dashboard_sidebar_open"
|
||||
|
||||
export function SiteHeader() {
|
||||
const t = useI18n()
|
||||
const pathname = usePathname()
|
||||
const { open, setOpen, isMobile } = useSidebar()
|
||||
const pageTitle = getPageTitle(pathname)
|
||||
const pageTitle = t(getPageTitleKey(pathname))
|
||||
const realtimeStatus = useAgentConversationsStore((state) => state.realtimeStatus)
|
||||
const hasRestoredRef = useRef(false)
|
||||
const showConversationRealtime =
|
||||
@@ -75,6 +78,7 @@ export function SiteHeader() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<LocaleSwitcher />
|
||||
<PaletteToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSyncExternalStore } from "react"
|
||||
import { LaptopIcon, MoonIcon, SunIcon } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -17,15 +18,16 @@ type ThemeMode = "light" | "dark" | "system"
|
||||
|
||||
const themeOptions: Array<{
|
||||
value: ThemeMode
|
||||
label: string
|
||||
labelKey: string
|
||||
icon: typeof SunIcon
|
||||
}> = [
|
||||
{ value: "light", label: "浅色模式", icon: SunIcon },
|
||||
{ value: "dark", label: "深色模式", icon: MoonIcon },
|
||||
{ value: "system", label: "跟随系统", icon: LaptopIcon },
|
||||
{ value: "light", labelKey: "theme.light", icon: SunIcon },
|
||||
{ value: "dark", labelKey: "theme.dark", icon: MoonIcon },
|
||||
{ value: "system", labelKey: "theme.system", icon: LaptopIcon },
|
||||
]
|
||||
|
||||
export function ThemeToggle() {
|
||||
const t = useI18n()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const mounted = useSyncExternalStore(
|
||||
() => () => {},
|
||||
@@ -39,9 +41,11 @@ export function ThemeToggle() {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />} aria-label="切换主题">
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="sm" />}
|
||||
aria-label={t("theme.toggle")}
|
||||
>
|
||||
<ActiveIcon />
|
||||
{/* <span className="hidden sm:inline">主题</span> */}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
@@ -53,7 +57,7 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<Icon />
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user