refactor: support i18n

This commit is contained in:
mlogclub
2026-05-25 12:06:15 +08:00
parent 309ac1fe9e
commit 988f55c80d
179 changed files with 10968 additions and 3763 deletions
@@ -34,6 +34,7 @@ import {
type AdminAgentTeamScheduleBatchPreview,
type BatchAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
type BatchScheduleDialogProps = {
@@ -42,15 +43,24 @@ type BatchScheduleDialogProps = {
onSuccess: (created: number) => void | Promise<void>
}
const weekdayOptions = [
{ value: 1, label: "周一" },
{ value: 2, label: "周二" },
{ value: 3, label: "周三" },
{ value: 4, label: "周四" },
{ value: 5, label: "周五" },
{ value: 6, label: "周六" },
{ value: 7, label: "周日" },
]
type TFunction = (key: string, values?: Record<string, string | number>) => string
const weekdayKeys = [
"weekdayMon",
"weekdayTue",
"weekdayWed",
"weekdayThu",
"weekdayFri",
"weekdaySat",
"weekdaySun",
] as const
function getWeekdayOptions(t: TFunction) {
return weekdayKeys.map((key, index) => ({
value: index + 1,
label: t(`agentTeamSchedule.${key}`),
}))
}
function todayDateValue() {
const today = new Date()
@@ -88,32 +98,33 @@ function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload
}
}
function getWeekdayLabel(value: number) {
return weekdayOptions.find((option) => option.value === value)?.label ?? `${value}`
function getWeekdayLabel(value: number, t: TFunction) {
const weekdayOptions = getWeekdayOptions(t)
return weekdayOptions.find((option) => option.value === value)?.label ?? String(value)
}
function validateForm(form: BatchFormState) {
function validateForm(form: BatchFormState, t: TFunction) {
const today = todayDateValue()
if (form.selectedTeamIds.length === 0) {
return "请选择至少一个客服组"
return t("agentTeamSchedule.selectAtLeastOneTeam")
}
if (!form.startDate || !form.endDate) {
return "请选择日期范围"
return t("agentTeamSchedule.selectDateRange")
}
if (form.startDate < today) {
return "开始日期不能早于今天"
return t("agentTeamSchedule.startBeforeToday")
}
if (form.endDate < form.startDate) {
return "结束日期不能早于开始日期"
return t("agentTeamSchedule.endBeforeStart")
}
if (form.weekdays.length === 0) {
return "请选择至少一个星期"
return t("agentTeamSchedule.selectAtLeastOneWeekday")
}
if (!form.startTime || !form.endTime) {
return "请选择开始和结束时间"
return t("agentTeamSchedule.selectStartEndTime")
}
if (form.endTime <= form.startTime) {
return "结束时间必须晚于开始时间"
return t("agentTeamSchedule.endAfterStart")
}
return ""
}
@@ -123,6 +134,7 @@ export function BatchScheduleDialog({
onOpenChange,
onSuccess,
}: BatchScheduleDialogProps) {
const t = useI18n()
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [form, setForm] = useState(defaultFormState)
const [step, setStep] = useState<DialogStep>("form")
@@ -134,6 +146,7 @@ export function BatchScheduleDialog({
const openRef = useRef(open)
const previewRequestIdRef = useRef(0)
const busy = loadingTeams || previewing || submitting
const weekdayOptions = useMemo(() => getWeekdayOptions(t), [t])
const teamOptions = useMemo(
() =>
@@ -181,7 +194,7 @@ export function BatchScheduleDialog({
}
} catch (error) {
if (!ignore) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
}
} finally {
if (!ignore) {
@@ -194,7 +207,7 @@ export function BatchScheduleDialog({
return () => {
ignore = true
}
}, [open])
}, [open, t])
function updateForm(values: Partial<BatchFormState>) {
previewRequestIdRef.current += 1
@@ -234,7 +247,7 @@ export function BatchScheduleDialog({
}
async function handlePreview() {
const validationMessage = validateForm(form)
const validationMessage = validateForm(form, t)
if (validationMessage) {
toast.error(validationMessage)
return
@@ -254,7 +267,7 @@ export function BatchScheduleDialog({
setStep("preview")
} catch (error) {
if (openRef.current && previewRequestIdRef.current === requestId) {
toast.error(error instanceof Error ? error.message : "预览批量排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.previewFailed"))
}
} finally {
if (openRef.current && previewRequestIdRef.current === requestId) {
@@ -265,11 +278,11 @@ export function BatchScheduleDialog({
async function handleSubmit() {
if (!preview || !previewPayload) {
toast.error("请先预览批量排班")
toast.error(t("agentTeamSchedule.previewFirst"))
return
}
if (preview.conflict) {
toast.error("存在冲突排班,不能提交")
toast.error(t("agentTeamSchedule.conflictCannotSubmit"))
return
}
@@ -277,17 +290,17 @@ export function BatchScheduleDialog({
setSubmitting(true)
try {
const data = await generateAgentTeamScheduleBatch(payload)
toast.success(`已创建 ${data.created} 条客服组排班`)
toast.success(t("agentTeamSchedule.batchCreated", { count: data.created }))
setPreview(null)
setPreviewPayload(null)
onOpenChange(false)
try {
await onSuccess(data.created)
} catch (error) {
toast.error(error instanceof Error ? `排班已生成,但刷新列表失败:${error.message}` : "排班已生成,但刷新列表失败")
toast.error(error instanceof Error ? t("agentTeamSchedule.batchRefreshFailed", { message: error.message }) : t("agentTeamSchedule.batchRefreshFailedFallback"))
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成批量排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.generateFailed"))
} finally {
setSubmitting(false)
}
@@ -297,9 +310,9 @@ export function BatchScheduleDialog({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle></DialogTitle>
<DialogTitle>{t("agentTeamSchedule.batch")}</DialogTitle>
<DialogDescription>
{t("agentTeamSchedule.batchDescription")}
</DialogDescription>
</DialogHeader>
@@ -307,15 +320,15 @@ export function BatchScheduleDialog({
{step === "form" ? (
<div className="space-y-5">
<div className="space-y-2">
<Label></Label>
<Label>{t("agentTeamSchedule.team")}</Label>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<OptionCombobox
value=""
options={teamOptions}
placeholder={loadingTeams ? "加载客服组中..." : "添加客服组"}
searchPlaceholder="搜索客服组"
emptyText={teams.length === 0 ? "暂无客服组" : "已选择全部客服组"}
placeholder={loadingTeams ? t("agentTeamSchedule.loadingTeams") : t("agentTeamSchedule.addTeam")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={teams.length === 0 ? t("agentTeamSchedule.noTeams") : t("agentTeamSchedule.allTeamsSelected")}
disabled={loadingTeams}
onChange={handleTeamSelect}
/>
@@ -332,7 +345,7 @@ export function BatchScheduleDialog({
size="icon-sm"
className="size-5 rounded-sm"
onClick={() => removeTeam(team.id)}
aria-label={`移除${team.name}`}
aria-label={t("agentTeamSchedule.removeTeam", { name: team.name })}
>
<XIcon className="size-3" />
</Button>
@@ -340,13 +353,13 @@ export function BatchScheduleDialog({
))}
</div>
) : (
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{t("agentTeamSchedule.noSelectedTeams")}</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="batch-schedule-start-date"></Label>
<Label htmlFor="batch-schedule-start-date">{t("agentTeamSchedule.startDate")}</Label>
<Input
id="batch-schedule-start-date"
type="date"
@@ -356,7 +369,7 @@ export function BatchScheduleDialog({
/>
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-end-date"></Label>
<Label htmlFor="batch-schedule-end-date">{t("agentTeamSchedule.endDate")}</Label>
<Input
id="batch-schedule-end-date"
type="date"
@@ -368,7 +381,7 @@ export function BatchScheduleDialog({
</div>
<div className="space-y-2">
<Label></Label>
<Label>{t("agentTeamSchedule.weekday")}</Label>
<div className="flex flex-wrap gap-2">
{weekdayOptions.map((option) => {
const selected = selectedWeekdays.has(option.value)
@@ -391,7 +404,7 @@ export function BatchScheduleDialog({
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="batch-schedule-start-time"></Label>
<Label htmlFor="batch-schedule-start-time">{t("agentTeamSchedule.startTime")}</Label>
<Input
id="batch-schedule-start-time"
type="time"
@@ -400,7 +413,7 @@ export function BatchScheduleDialog({
/>
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-end-time"></Label>
<Label htmlFor="batch-schedule-end-time">{t("agentTeamSchedule.endTime")}</Label>
<Input
id="batch-schedule-end-time"
type="time"
@@ -411,11 +424,11 @@ export function BatchScheduleDialog({
</div>
<div className="space-y-2">
<Label htmlFor="batch-schedule-remark"></Label>
<Label htmlFor="batch-schedule-remark">{t("agentTeamSchedule.remark")}</Label>
<Textarea
id="batch-schedule-remark"
rows={4}
placeholder="请输入备注"
placeholder={t("agentTeamSchedule.remarkPlaceholder")}
value={form.remark}
onChange={(event) => updateForm({ remark: event.target.value })}
/>
@@ -425,13 +438,14 @@ export function BatchScheduleDialog({
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm text-muted-foreground">
{preview?.total ?? 0}
{hasConflict ? ",存在冲突,请返回调整" : ",确认无冲突后可生成"}
{hasConflict
? t("agentTeamSchedule.previewSummaryConflict", { total: preview?.total ?? 0 })
: t("agentTeamSchedule.previewSummaryReady", { total: preview?.total ?? 0 })}
</div>
{hasConflict ? (
<Badge variant="destructive"></Badge>
<Badge variant="destructive">{t("agentTeamSchedule.hasConflict")}</Badge>
) : (
<Badge variant="secondary"></Badge>
<Badge variant="secondary">{t("agentTeamSchedule.noConflict")}</Badge>
)}
</div>
<div className="overflow-x-auto rounded-lg border">
@@ -439,12 +453,12 @@ export function BatchScheduleDialog({
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead>{t("agentTeamSchedule.date")}</TableHead>
<TableHead>{t("agentTeamSchedule.weekday")}</TableHead>
<TableHead>{t("agentTeamSchedule.time")}</TableHead>
<TableHead>{t("agentTeamSchedule.remark")}</TableHead>
<TableHead>{t("agentTeamSchedule.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -456,11 +470,11 @@ export function BatchScheduleDialog({
)}
>
<TableCell>
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div>
<div className="text-xs text-muted-foreground">ID{item.teamId}</div>
<div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
</TableCell>
<TableCell>{item.date}</TableCell>
<TableCell>{getWeekdayLabel(item.weekday)}</TableCell>
<TableCell>{getWeekdayLabel(item.weekday, t)}</TableCell>
<TableCell>
{item.startAt.slice(11, 16)} - {item.endAt.slice(11, 16)}
</TableCell>
@@ -468,10 +482,10 @@ export function BatchScheduleDialog({
<TableCell>
{item.conflict ? (
<span className="font-medium">
{item.conflictReason || "排班冲突"}
{item.conflictReason || t("agentTeamSchedule.conflictFallback")}
</span>
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("agentTeamSchedule.canGenerate")}</span>
)}
</TableCell>
</TableRow>
@@ -479,7 +493,7 @@ export function BatchScheduleDialog({
{preview && preview.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
{t("agentTeamSchedule.emptyPreview")}
</TableCell>
</TableRow>
) : null}
@@ -500,7 +514,7 @@ export function BatchScheduleDialog({
disabled={submitting}
>
<ArrowLeftIcon />
{t("agentTeamSchedule.backToEdit")}
</Button>
) : null}
<Button
@@ -509,12 +523,12 @@ export function BatchScheduleDialog({
onClick={() => handleOpenChange(false)}
disabled={busy}
>
{t("agentTeamSchedule.cancel")}
</Button>
{step === "form" ? (
<Button type="button" onClick={() => void handlePreview()} disabled={busy}>
{previewing ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
{t("agentTeamSchedule.preview")}
</Button>
) : (
<Button
@@ -523,7 +537,7 @@ export function BatchScheduleDialog({
disabled={submitting || hasConflict || !preview || !previewPayload || preview.items.length === 0}
>
{submitting ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
{t("agentTeamSchedule.generate")}
</Button>
)}
</DialogFooter>
@@ -58,7 +58,7 @@ export function formatDateTimeValue(date: Date) {
}
export function formatMonthTitle(monthStart: Date) {
return `${monthStart.getFullYear()}${String(monthStart.getMonth() + 1).padStart(2, "0")}`
return `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}`
}
function formatDate(date: Date) {
@@ -13,7 +13,17 @@ import { cn, formatDateTime } from "@/lib/utils"
import { isSameLocalDay } from "./calendar-date-range"
import { buildDayTimeLayout } from "./calendar-time-layout"
const weekDayNames = ["一", "二", "三", "四", "五", "六", "日"]
type TFunction = (key: string, values?: Record<string, string | number>) => string
const weekDayKeys = [
"weekdayShortMon",
"weekdayShortTue",
"weekdayShortWed",
"weekdayShortThu",
"weekdayShortFri",
"weekdayShortSat",
"weekdayShortSun",
] as const
const dayMs = 24 * 60 * 60 * 1000
const minuteMs = 60 * 1000
const minDurationMs = 15 * minuteMs
@@ -31,6 +41,7 @@ type ScheduleCalendarProps = {
onEdit: (item: AdminAgentTeamSchedule) => void
onMove: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
onResize: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
t: TFunction
}
type DragState =
@@ -226,6 +237,7 @@ export function ScheduleCalendar({
onEdit,
onMove,
onResize,
t,
}: ScheduleCalendarProps) {
const days = buildCalendarDays(calendarStart, calendarEnd)
const defaultTeamID = teams[0]?.id ?? 0
@@ -250,7 +262,7 @@ export function ScheduleCalendar({
return {
itemId: state.item.id,
date: null,
label: "拖到日历日期格内",
label: t("agentTeamSchedule.dropInsideCalendar"),
invalid: true,
x: pointerEvent.clientX,
y: pointerEvent.clientY,
@@ -265,7 +277,7 @@ export function ScheduleCalendar({
return {
itemId: state.item.id,
date,
label: "不能修改历史日期",
label: t("agentTeamSchedule.historyReadonly"),
invalid: true,
x: pointerEvent.clientX,
y: pointerEvent.clientY,
@@ -274,11 +286,11 @@ export function ScheduleCalendar({
const point = { x: pointerEvent.clientX, y: pointerEvent.clientY }
if (state.type === "move") {
return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, "无法移动到这里")
return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, t("agentTeamSchedule.cannotMoveHere"))
}
const payload = buildResizePayload(state.item, state.edge, getPointerDateInCell(pointerEvent, cell))
return buildPreviewFromPayload(state.item.id, date, payload, point, "不能跨天或少于 15 分钟")
return buildPreviewFromPayload(state.item.id, date, payload, point, t("agentTeamSchedule.resizeInvalid"))
}
function cleanupPointerInteraction(
@@ -365,7 +377,7 @@ export function ScheduleCalendar({
if (teams.length === 0 && !loading) {
return (
<div className="flex min-h-64 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground">
{t("agentTeamSchedule.noTeamsCalendar")}
</div>
)
}
@@ -426,7 +438,7 @@ export function ScheduleCalendar({
<div className="flex shrink-0 items-center gap-1">
{today ? (
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground">
{t("agentTeamSchedule.today")}
</span>
) : null}
{historical ? null : <CalendarPlusIcon className="size-3.5 text-muted-foreground" />}
@@ -434,7 +446,7 @@ export function ScheduleCalendar({
</div>
<div className="space-y-1">
{daySchedules.slice(0, 5).map((item) => {
const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || `客服组#${item.teamId}`
const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || t("agentTeamSchedule.teamFallback", { id: item.teamId })
const busy = savingId === item.id
const active = interactionPreview?.itemId === item.id
const timeLayout = dayTimeLayout.items.get(item.id)
@@ -512,7 +524,7 @@ export function ScheduleCalendar({
)
})}
{daySchedules.length > 5 ? (
<div className="text-xs text-muted-foreground"> {daySchedules.length - 5} </div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.moreItems", { count: daySchedules.length - 5 })}</div>
) : null}
</div>
</div>
@@ -528,7 +540,7 @@ export function ScheduleCalendar({
return (
<div key={date} className="grid grid-cols-[112px_minmax(0,1fr)]">
<div className="border-r bg-muted/40 px-3 py-3 text-sm font-medium">
<div>{weekDayNames[dayIndex] ?? ""}</div>
<div>{t(`agentTeamSchedule.${weekDayKeys[dayIndex] ?? "weekdayShortMon"}`)}</div>
<div className="mt-1 text-xs font-normal text-muted-foreground">{date.slice(5)}</div>
</div>
{renderDayCell(day, dayIndex, {
@@ -562,9 +574,9 @@ export function ScheduleCalendar({
return (
<div className="min-w-[960px] overflow-hidden rounded-lg border bg-background">
<div className="grid grid-cols-7 border-b bg-muted/40">
{weekDayNames.map((name) => (
<div key={name} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
{name}
{weekDayKeys.map((key) => (
<div key={key} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
{t(`agentTeamSchedule.${key}`)}
</div>
))}
</div>
@@ -1,7 +1,7 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Controller, type Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
@@ -27,8 +27,11 @@ import {
type AdminAgentTeamSchedule,
type CreateAdminAgentTeamSchedulePayload,
fetchAgentTeamSchedule,
fetchAgentTeamsAll
fetchAgentTeamsAll,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type TFunction = (key: string, values?: Record<string, string | number>) => string
type ScheduleEditDialogProps = {
open: boolean
@@ -47,10 +50,18 @@ const emptyForm: EditForm = {
remark: "",
}
const editFormSchema = z.object({
teamId: z.string().trim().regex(/^\d+$/, "请选择客服组"),
startAt: z.string().trim().min(1, "开始时间不能为空"),
endAt: z.string().trim().min(1, "结束时间不能为空"),
type EditForm = {
teamId: string
startAt: string
endAt: string
remark: string
}
function createEditFormSchema(t: TFunction) {
return z.object({
teamId: z.string().trim().regex(/^\d+$/, t("agentTeamSchedule.teamRequired")),
startAt: z.string().trim().min(1, t("agentTeamSchedule.startRequired")),
endAt: z.string().trim().min(1, t("agentTeamSchedule.endRequired")),
remark: z.string().trim(),
}).superRefine((value, ctx) => {
const startAt = parseDateTimeLocal(value.startAt)
@@ -62,7 +73,7 @@ const editFormSchema = z.object({
ctx.addIssue({
code: "custom",
path: ["endAt"],
message: "结束时间必须晚于开始时间",
message: t("agentTeamSchedule.endAfterStart"),
})
return
}
@@ -70,24 +81,18 @@ const editFormSchema = z.object({
ctx.addIssue({
code: "custom",
path: ["endAt"],
message: "单条排班记录不能跨天",
message: t("agentTeamSchedule.singleDayOnly"),
})
}
if (startAt < startOfLocalDay(new Date())) {
ctx.addIssue({
code: "custom",
path: ["startAt"],
message: "不能添加或修改历史日期的排班",
message: t("agentTeamSchedule.historyReadonly"),
})
}
})
type EditForm = z.infer<typeof editFormSchema>
const editFormResolver = zodResolver(editFormSchema as never) as Resolver<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>
}
function toDateTimeLocal(value?: string) {
if (!value) {
@@ -180,6 +185,7 @@ function ScheduleEditDialogBody({
onSubmit,
onDelete,
}: ScheduleEditDialogBodyProps) {
const t = useI18n()
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [loading, setLoading] = useState(false)
const loadOptions = useCallback(async () => {
@@ -187,14 +193,15 @@ function ScheduleEditDialogBody({
const teamsData = await fetchAgentTeamsAll()
setTeams(teamsData)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadOptionsFailed"))
}
}, [])
const form = useForm<
z.input<typeof editFormSchema>,
undefined,
z.output<typeof editFormSchema>
>({
}, [t])
const editFormSchema = useMemo(() => createEditFormSchema(t), [t])
const editFormResolver = useMemo(
() => zodResolver(editFormSchema) as Resolver<EditForm>,
[editFormSchema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
@@ -218,13 +225,13 @@ function ScheduleEditDialogBody({
const data = await fetchAgentTeamSchedule(itemId)
reset(buildForm(data))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班详情失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadDetailFailed"))
} finally {
setLoading(false)
}
}
void loadDetail()
}, [defaultValues, itemId, reset])
}, [defaultValues, itemId, reset, t])
useEffect(() => {
void loadOptions()
@@ -237,18 +244,18 @@ function ScheduleEditDialogBody({
return (
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{itemId ? "编辑客服组排班" : "新建客服组排班"}</DialogTitle>
<DialogTitle>{itemId ? t("agentTeamSchedule.editTitle") : t("agentTeamSchedule.createTitle")}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
<div className="text-muted-foreground">{t("agentTeamSchedule.loading")}</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<div className="grid grid-cols-1 gap-4">
<Field data-invalid={!!errors.teamId}>
<FieldLabel></FieldLabel>
<FieldLabel>{t("agentTeamSchedule.team")}</FieldLabel>
<FieldContent>
<Controller
control={control}
@@ -260,8 +267,9 @@ function ScheduleEditDialogBody({
value: String(team.id),
label: team.name,
}))}
placeholder="请选择客服组"
searchPlaceholder="搜索客服组"
placeholder={t("agentTeamSchedule.teamRequired")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={t("agentTeamSchedule.emptyTeam")}
onChange={field.onChange}
/>
)}
@@ -272,14 +280,14 @@ function ScheduleEditDialogBody({
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.startAt}>
<FieldLabel htmlFor="agent-team-schedule-start-at"></FieldLabel>
<FieldLabel htmlFor="agent-team-schedule-start-at">{t("agentTeamSchedule.startTime")}</FieldLabel>
<FieldContent>
<Input id="agent-team-schedule-start-at" type="datetime-local" min={minDateTime} {...register("startAt")} />
<FieldError errors={[errors.startAt]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.endAt}>
<FieldLabel htmlFor="agent-team-schedule-end-at"></FieldLabel>
<FieldLabel htmlFor="agent-team-schedule-end-at">{t("agentTeamSchedule.endTime")}</FieldLabel>
<FieldContent>
<Input id="agent-team-schedule-end-at" type="datetime-local" min={minDateTime} {...register("endAt")} />
<FieldError errors={[errors.endAt]} />
@@ -287,23 +295,23 @@ function ScheduleEditDialogBody({
</Field>
</div>
<Field>
<FieldLabel htmlFor="agent-team-schedule-remark"></FieldLabel>
<FieldLabel htmlFor="agent-team-schedule-remark">{t("agentTeamSchedule.remark")}</FieldLabel>
<FieldContent>
<Textarea id="agent-team-schedule-remark" rows={4} placeholder="请输入备注" {...register("remark")} />
<Textarea id="agent-team-schedule-remark" rows={4} placeholder={t("agentTeamSchedule.remarkPlaceholder")} {...register("remark")} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
{itemId && onDelete ? (
<Button type="button" variant="destructive" onClick={() => void onDelete(itemId)} disabled={saving}>
{t("agentTeamSchedule.delete")}
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("agentTeamSchedule.cancel")}
</Button>
<Button type="submit" disabled={saving || loading}>
{saving ? "保存中..." : "保存"}
{saving ? t("agentTeamSchedule.saving") : t("agentTeamSchedule.save")}
</Button>
</DialogFooter>
</form>
+51 -44
View File
@@ -49,6 +49,7 @@ import {
type PageResult,
type UpdateAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
import { BatchScheduleDialog } from "./_components/batch-schedule-dialog"
import { ScheduleCalendar } from "./_components/calendar"
@@ -56,7 +57,6 @@ import {
addDays,
addMonths,
formatDateTimeValue,
formatMonthTitle,
formatWeekTitle,
startOfDay,
startOfMonth,
@@ -79,6 +79,7 @@ function isHistoricalSchedule(item: AdminAgentTeamSchedule) {
}
export default function DashboardAgentTeamSchedulesPage() {
const t = useI18n()
const [viewMode, setViewMode] = useState<ViewMode>("month")
const [teamFilterInput, setTeamFilterInput] = useState("all")
const [teamFilter, setTeamFilter] = useState("all")
@@ -120,11 +121,11 @@ export default function DashboardAgentTeamSchedulesPage() {
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadFailed"))
} finally {
setLoading(false)
}
}, [limit, page, teamFilter])
}, [limit, page, t, teamFilter])
const loadCalendarData = useCallback(async () => {
setCalendarLoading(true)
@@ -138,20 +139,20 @@ export default function DashboardAgentTeamSchedulesPage() {
})
setCalendarItems(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班日历失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadCalendarFailed"))
} finally {
setCalendarLoading(false)
}
}, [monthStart, teamFilter, viewMode, weekStart])
}, [monthStart, t, teamFilter, viewMode, weekStart])
const loadTeams = useCallback(async () => {
try {
const data = await fetchAgentTeamsAll()
setTeams(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
}
}, [])
}, [t])
const refreshActiveView = useCallback(async () => {
await Promise.all([
@@ -194,7 +195,7 @@ export default function DashboardAgentTeamSchedulesPage() {
function openEditDialog(item: AdminAgentTeamSchedule) {
if (isHistoricalSchedule(item)) {
toast.error("不能修改历史日期的排班")
toast.error(t("agentTeamSchedule.historyReadonly"))
return
}
setDialogDefaults(null)
@@ -221,17 +222,17 @@ export default function DashboardAgentTeamSchedulesPage() {
try {
if (editingItem) {
await updateAgentTeamSchedule({ id: editingItem.id, ...payload })
toast.success("已更新客服组排班")
toast.success(t("agentTeamSchedule.updated"))
} else {
await createAgentTeamSchedule(payload)
toast.success("已创建客服组排班")
toast.success(t("agentTeamSchedule.created"))
}
setDialogOpen(false)
setEditingItem(null)
setDialogDefaults(null)
await refreshActiveView()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.saveFailed"))
} finally {
setSaving(false)
}
@@ -245,13 +246,13 @@ export default function DashboardAgentTeamSchedulesPage() {
setActionLoadingId(id)
try {
await deleteAgentTeamSchedule(id)
toast.success("已删除客服组排班")
toast.success(t("agentTeamSchedule.deleted"))
setDialogOpen(false)
setEditingItem(null)
setDialogDefaults(null)
await refreshActiveView()
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.deleteFailed"))
} finally {
setActionLoadingId(null)
}
@@ -264,16 +265,16 @@ export default function DashboardAgentTeamSchedulesPage() {
async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) {
const startAt = parseLocalDateTime(payload.startAt)
if (startAt && startAt < startOfDay(new Date())) {
toast.error("不能修改历史日期的排班")
toast.error(t("agentTeamSchedule.historyReadonly"))
return
}
setActionLoadingId(payload.id)
try {
await updateAgentTeamSchedule(payload)
toast.success("已更新客服组排班")
toast.success(t("agentTeamSchedule.updated"))
await loadCalendarData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新客服组排班失败")
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.updateFailed"))
await loadCalendarData()
} finally {
setActionLoadingId(null)
@@ -298,7 +299,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("month")}
>
<CalendarDaysIcon />
{t("agentTeamSchedule.month")}
</Button>
<Button
variant={viewMode === "week" ? "default" : "outline"}
@@ -306,7 +307,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("week")}
>
<CalendarRangeIcon />
{t("agentTeamSchedule.week")}
</Button>
<Button
variant={viewMode === "list" ? "default" : "outline"}
@@ -314,37 +315,42 @@ export default function DashboardAgentTeamSchedulesPage() {
onClick={() => setViewMode("list")}
>
<ListIcon />
{t("agentTeamSchedule.list")}
</Button>
</ButtonGroup>
{viewMode === "month" ? (
<ButtonGroup>
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label="上一月">
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label={t("agentTeamSchedule.prevMonth")}>
<ChevronLeftIcon />
</Button>
<Button variant="outline" size="sm" onClick={() => setMonthStart(startOfMonth(new Date()))}>
{t("agentTeamSchedule.thisMonth")}
</Button>
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label="下一月">
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label={t("agentTeamSchedule.nextMonth")}>
<ChevronRightIcon />
</Button>
</ButtonGroup>
) : null}
{viewMode === "week" ? (
<ButtonGroup>
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label="上一周">
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label={t("agentTeamSchedule.prevWeek")}>
<ChevronLeftIcon />
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
{t("agentTeamSchedule.thisWeek")}
</Button>
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label="下一周">
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label={t("agentTeamSchedule.nextWeek")}>
<ChevronRightIcon />
</Button>
</ButtonGroup>
) : null}
{viewMode === "month" ? (
<div className="text-sm text-muted-foreground">{formatMonthTitle(monthStart)}</div>
<div className="text-sm text-muted-foreground">
{t("agentTeamSchedule.monthTitle", {
year: monthStart.getFullYear(),
month: String(monthStart.getMonth() + 1).padStart(2, "0"),
})}
</div>
) : null}
{viewMode === "week" ? (
<div className="text-sm text-muted-foreground">{formatWeekTitle(weekStart)}</div>
@@ -352,7 +358,7 @@ export default function DashboardAgentTeamSchedulesPage() {
{viewMode !== "list" ? (
<Button variant="outline" size="sm" onClick={goToToday}>
<CalendarSearchIcon />
{t("agentTeamSchedule.today")}
</Button>
) : null}
</div>
@@ -362,18 +368,18 @@ export default function DashboardAgentTeamSchedulesPage() {
<OptionCombobox
value={teamFilterInput}
options={[
{ value: "all", label: "全部客服组" },
{ value: "all", label: t("agentTeamSchedule.allTeams") },
...teams.map((team) => ({ value: String(team.id), label: team.name })),
]}
placeholder="筛选客服组"
searchPlaceholder="搜索客服组"
emptyText="未找到客服组"
placeholder={t("agentTeamSchedule.filterTeam")}
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
emptyText={t("agentTeamSchedule.emptyTeam")}
onChange={(value) => setTeamFilterInput(value)}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={refreshing}>
<SearchIcon />
{t("agentTeamSchedule.query")}
</Button>
<Button
variant="outline"
@@ -381,15 +387,15 @@ export default function DashboardAgentTeamSchedulesPage() {
disabled={refreshing}
>
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} />
{t("agentTeamSchedule.refresh")}
</Button>
<Button variant="outline" onClick={() => setBatchDialogOpen(true)}>
<LayersIcon />
{t("agentTeamSchedule.batch")}
</Button>
<Button onClick={() => openCreateDialog()}>
<PlusIcon />
{t("agentTeamSchedule.new")}
</Button>
</div>
</div>
@@ -409,6 +415,7 @@ export default function DashboardAgentTeamSchedulesPage() {
onEdit={openEditDialog}
onMove={handleCalendarUpdate}
onResize={handleCalendarUpdate}
t={t}
/>
</div>
) : (
@@ -417,9 +424,9 @@ export default function DashboardAgentTeamSchedulesPage() {
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[92px] text-right"></TableHead>
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead>{t("agentTeamSchedule.timeRange")}</TableHead>
<TableHead className="w-[92px] text-right">{t("agentTeamSchedule.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -431,8 +438,8 @@ export default function DashboardAgentTeamSchedulesPage() {
<CalendarClockIcon className="size-4" />
</div>
<div className="min-w-0">
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div>
<div className="text-xs text-muted-foreground">ID{item.teamId}</div>
<div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
</div>
</div>
</TableCell>
@@ -443,12 +450,12 @@ export default function DashboardAgentTeamSchedulesPage() {
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)} disabled={isHistoricalSchedule(item)}>
{t("agentTeamSchedule.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.startAt}`}
aria-label={t("agentTeamSchedule.moreActions", { name: item.startAt })}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
@@ -458,7 +465,7 @@ export default function DashboardAgentTeamSchedulesPage() {
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
{actionLoadingId === item.id ? t("agentTeamSchedule.deleting") : t("agentTeamSchedule.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -469,7 +476,7 @@ export default function DashboardAgentTeamSchedulesPage() {
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
{t("agentTeamSchedule.emptyRows")}
</TableCell>
</TableRow>
) : null}