调整目录
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { createTicketFromConversation } from "@/lib/api/ticket"
|
||||
import { EditDialog } from "./edit"
|
||||
|
||||
type ConversationSeed = {
|
||||
id: number
|
||||
subject: string
|
||||
customerId?: number
|
||||
lastMessageSummary?: string
|
||||
currentAssigneeId?: number
|
||||
}
|
||||
|
||||
type CreateTicketFromConversationDialogProps = {
|
||||
open: boolean
|
||||
conversation: ConversationSeed | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function CreateTicketFromConversationDialog({
|
||||
open,
|
||||
conversation,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: CreateTicketFromConversationDialogProps) {
|
||||
const initialValues = conversation
|
||||
? {
|
||||
title: conversation.subject || "",
|
||||
description: conversation.lastMessageSummary || "",
|
||||
priority: 2,
|
||||
severity: 1,
|
||||
currentAssigneeId: conversation.currentAssigneeId || undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={false}
|
||||
itemId={null}
|
||||
onOpenChange={onOpenChange}
|
||||
fixedConversationId={conversation?.id}
|
||||
fixedCustomerId={conversation?.customerId}
|
||||
initialValues={initialValues}
|
||||
titleOverride="会话转工单"
|
||||
descriptionOverride="从当前会话上下文创建正式工单"
|
||||
onSubmit={async (payload) => {
|
||||
if (!conversation?.id) {
|
||||
throw new Error("会话不存在")
|
||||
}
|
||||
await createTicketFromConversation({
|
||||
conversationId: conversation.id,
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
priority: payload.priority,
|
||||
severity: payload.severity,
|
||||
currentTeamId: payload.currentTeamId,
|
||||
currentAssigneeId: payload.currentAssigneeId,
|
||||
syncToConversation: true,
|
||||
})
|
||||
toast.success("工单创建成功")
|
||||
onSuccess?.()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
"use client"
|
||||
|
||||
import { CheckIcon, TagIcon } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import type { Resolver } from "react-hook-form"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
fetchAgentProfilesAll,
|
||||
fetchAgentTeamsAll,
|
||||
fetchTagsAll,
|
||||
type AdminAgentProfile,
|
||||
type AdminAgentTeam,
|
||||
type TagTree,
|
||||
} from "@/lib/api/admin"
|
||||
import {
|
||||
fetchTicketPriorityConfigsAll,
|
||||
type TicketPriorityConfig,
|
||||
} from "@/lib/api/ticket-config"
|
||||
import {
|
||||
fetchTicketDetail,
|
||||
type CreateTicketPayload,
|
||||
type TicketItem,
|
||||
type UpdateTicketPayload,
|
||||
} from "@/lib/api/ticket"
|
||||
|
||||
type EditDialogProps = {
|
||||
open: boolean
|
||||
saving: boolean
|
||||
itemId: number | null
|
||||
initialValues?: Partial<CreateTicketPayload>
|
||||
fixedConversationId?: number
|
||||
fixedCustomerId?: number
|
||||
titleOverride?: string
|
||||
descriptionOverride?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
|
||||
}
|
||||
|
||||
const ticketFormSchema = z.object({
|
||||
title: z.string().trim().min(1, "标题不能为空"),
|
||||
description: z.string().trim(),
|
||||
tagIds: z.array(z.string().trim()).default([]),
|
||||
priority: z.string().trim().min(1, "请选择优先级"),
|
||||
severity: z.enum(["1", "2", "3"], { message: "请选择严重度" }),
|
||||
currentTeamId: z.string().trim(),
|
||||
currentAssigneeId: z.string().trim(),
|
||||
dueAt: z.string().trim(),
|
||||
})
|
||||
|
||||
type EditForm = z.infer<typeof ticketFormSchema>
|
||||
|
||||
const editFormResolver = zodResolver(ticketFormSchema as never) as Resolver<
|
||||
z.input<typeof ticketFormSchema>,
|
||||
undefined,
|
||||
z.output<typeof ticketFormSchema>
|
||||
>
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
title: "",
|
||||
description: "",
|
||||
tagIds: [],
|
||||
priority: "",
|
||||
severity: "1",
|
||||
currentTeamId: "",
|
||||
currentAssigneeId: "",
|
||||
dueAt: "",
|
||||
}
|
||||
|
||||
function buildForm(item: TicketItem | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm
|
||||
}
|
||||
return {
|
||||
title: item.title ?? "",
|
||||
description: item.description ?? "",
|
||||
tagIds: (item.tags ?? []).map((tag) => String(tag.id)),
|
||||
priority: item.priority ? String(item.priority) : "",
|
||||
severity: String(item.severity || 1) as EditForm["severity"],
|
||||
currentTeamId: item.currentTeamId ? String(item.currentTeamId) : "",
|
||||
currentAssigneeId: item.currentAssigneeId ? String(item.currentAssigneeId) : "",
|
||||
dueAt: item.dueAt ? item.dueAt.replace(" ", "T").slice(0, 16) : "",
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialForm(initialValues?: Partial<CreateTicketPayload>): EditForm {
|
||||
return {
|
||||
title: initialValues?.title?.trim() ?? "",
|
||||
description: initialValues?.description?.trim() ?? "",
|
||||
tagIds: (initialValues?.tagIds ?? []).map((tagId) => String(tagId)),
|
||||
priority: initialValues?.priority ? String(initialValues.priority) : "",
|
||||
severity: String(initialValues?.severity ?? 1) as EditForm["severity"],
|
||||
currentTeamId: initialValues?.currentTeamId ? String(initialValues.currentTeamId) : "",
|
||||
currentAssigneeId: initialValues?.currentAssigneeId
|
||||
? String(initialValues.currentAssigneeId)
|
||||
: "",
|
||||
dueAt: initialValues?.dueAt ? initialValues.dueAt.replace(" ", "T").slice(0, 16) : "",
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateTicketPayload {
|
||||
return {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
tagIds: form.tagIds.length > 0 ? form.tagIds.map((tagId) => Number(tagId)) : undefined,
|
||||
priority: Number(form.priority),
|
||||
severity: Number(form.severity),
|
||||
currentTeamId: form.currentTeamId ? Number(form.currentTeamId) : undefined,
|
||||
currentAssigneeId: form.currentAssigneeId ? Number(form.currentAssigneeId) : undefined,
|
||||
dueAt: form.dueAt ? `${form.dueAt.replace("T", " ")}:00` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
type FlatTagNode = TagTree & {
|
||||
depth: number
|
||||
path: string
|
||||
}
|
||||
|
||||
function flattenTagTree(nodes: TagTree[], depth = 0, parentPath = ""): FlatTagNode[] {
|
||||
const result: FlatTagNode[] = []
|
||||
nodes.forEach((item) => {
|
||||
const path = parentPath ? `${parentPath} / ${item.name}` : item.name
|
||||
result.push({ ...item, depth, path })
|
||||
if (item.children.length > 0) {
|
||||
result.push(...flattenTagTree(item.children, depth + 1, path))
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
type TicketTagSelectorProps = {
|
||||
value?: string[]
|
||||
onChange: (value: string[]) => void
|
||||
availableTags: TagTree[]
|
||||
}
|
||||
|
||||
function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelectorProps) {
|
||||
const selectedValues = value ?? []
|
||||
const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
|
||||
const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues])
|
||||
const selectedTags = useMemo(
|
||||
() => flatTags.filter((tag) => selectedTagIDs.has(String(tag.id))),
|
||||
[flatTags, selectedTagIDs],
|
||||
)
|
||||
|
||||
function handleToggle(tagID: string) {
|
||||
if (selectedTagIDs.has(tagID)) {
|
||||
onChange(selectedValues.filter((item) => item !== tagID))
|
||||
return
|
||||
}
|
||||
onChange(selectedValues.concat(tagID))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline" className="w-full justify-start" />
|
||||
}
|
||||
>
|
||||
<TagIcon className="size-4" />
|
||||
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[320px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="搜索标签" />
|
||||
<CommandList>
|
||||
<CommandEmpty>暂无可用标签</CommandEmpty>
|
||||
<CommandGroup heading="标签">
|
||||
{flatTags.map((tag) => {
|
||||
const checked = selectedTagIDs.has(String(tag.id))
|
||||
return (
|
||||
<CommandItem
|
||||
key={tag.id}
|
||||
value={`${tag.id} ${tag.path} ${tag.remark}`}
|
||||
onSelect={() => handleToggle(String(tag.id))}
|
||||
>
|
||||
<CheckIcon className={`mr-2 size-4 ${checked ? "opacity-100" : "opacity-0"}`} />
|
||||
<span className="truncate" style={{ paddingLeft: `${tag.depth * 12}px` }}>
|
||||
{tag.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTags.map((tag) => (
|
||||
<Badge key={tag.id} variant="outline">
|
||||
{tag.path}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
initialValues,
|
||||
fixedConversationId,
|
||||
fixedCustomerId,
|
||||
titleOverride,
|
||||
descriptionOverride,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: EditDialogProps) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<TicketEditDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
initialValues={initialValues}
|
||||
fixedConversationId={fixedConversationId}
|
||||
fixedCustomerId={fixedCustomerId}
|
||||
titleOverride={titleOverride}
|
||||
descriptionOverride={descriptionOverride}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TicketEditDialogBodyProps = EditDialogProps
|
||||
|
||||
function TicketEditDialogBody({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
initialValues,
|
||||
fixedConversationId,
|
||||
fixedCustomerId,
|
||||
titleOverride,
|
||||
descriptionOverride,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: TicketEditDialogBodyProps) {
|
||||
const formId = "ticket-edit-form"
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tags, setTags] = useState<TagTree[]>([])
|
||||
const [priorities, setPriorities] = useState<TicketPriorityConfig[]>([])
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const form = useForm<
|
||||
z.input<typeof ticketFormSchema>,
|
||||
undefined,
|
||||
z.output<typeof ticketFormSchema>
|
||||
>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
})
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(buildInitialForm(initialValues))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchTicketDetail(itemId)
|
||||
reset(buildForm(data.ticket))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void loadDetail()
|
||||
}, [initialValues, itemId, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const [tagData, priorityData, teamData, agentData] = await Promise.all([
|
||||
fetchTagsAll(),
|
||||
fetchTicketPriorityConfigsAll(),
|
||||
fetchAgentTeamsAll(),
|
||||
fetchAgentProfilesAll(),
|
||||
])
|
||||
setTags(Array.isArray(tagData) ? tagData : [])
|
||||
setPriorities(Array.isArray(priorityData) ? priorityData : [])
|
||||
setTeams(Array.isArray(teamData) ? teamData : [])
|
||||
setAgents(Array.isArray(agentData) ? agentData : [])
|
||||
})()
|
||||
}, [open])
|
||||
|
||||
const priorityOptions = priorities.map((priority) => ({
|
||||
value: String(priority.id),
|
||||
label: priority.name,
|
||||
}))
|
||||
|
||||
const teamOptions = [{ value: "", label: "不指定团队" }].concat(
|
||||
teams.map((team) => ({
|
||||
value: String(team.id),
|
||||
label: team.name,
|
||||
})),
|
||||
)
|
||||
const agentOptions = [{ value: "", label: "不指定处理人" }].concat(
|
||||
agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label:
|
||||
agent.displayName ||
|
||||
agent.nickname ||
|
||||
agent.username ||
|
||||
`客服#${agent.userId}`,
|
||||
})),
|
||||
)
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
const payload = buildPayload(values)
|
||||
if (itemId) {
|
||||
await onSubmit({
|
||||
ticketId: itemId,
|
||||
...payload,
|
||||
})
|
||||
return
|
||||
}
|
||||
await onSubmit({
|
||||
...payload,
|
||||
source: fixedConversationId ? "conversation" : "manual",
|
||||
conversationId: fixedConversationId,
|
||||
customerId: fixedCustomerId,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={titleOverride || (itemId ? "编辑工单" : "新建工单")}
|
||||
description={descriptionOverride || "填写工单基础信息"}
|
||||
size="lg"
|
||||
allowFullscreen
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? "保存中..." : itemId ? "保存" : "创建"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.title}>
|
||||
<FieldLabel htmlFor="ticket-title">标题</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-title"
|
||||
placeholder="请输入工单标题"
|
||||
aria-invalid={!!errors.title}
|
||||
{...register("title")}
|
||||
/>
|
||||
<FieldError errors={[errors.title]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.description}>
|
||||
<FieldLabel htmlFor="ticket-description">描述</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="ticket-description"
|
||||
rows={5}
|
||||
placeholder="请输入问题描述"
|
||||
aria-invalid={!!errors.description}
|
||||
{...register("description")}
|
||||
/>
|
||||
<FieldError errors={[errors.description]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel>工单标签</FieldLabel>
|
||||
</div>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tagIds"
|
||||
render={({ field }) => (
|
||||
<TicketTagSelector
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
availableTags={tags}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field data-invalid={!!errors.priority}>
|
||||
<FieldLabel>优先级</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择优先级"
|
||||
options={priorityOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.priority]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.severity}>
|
||||
<FieldLabel>严重度</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="severity"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择严重度"
|
||||
options={[
|
||||
{ value: "1", label: "轻微" },
|
||||
{ value: "2", label: "严重" },
|
||||
{ value: "3", label: "致命" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.severity]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>处理团队</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="currentTeamId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择团队"
|
||||
options={teamOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>处理人</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="currentAssigneeId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择处理人"
|
||||
options={agentOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={!!errors.dueAt}>
|
||||
<FieldLabel htmlFor="ticket-due-at">截止时间</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-due-at"
|
||||
type="datetime-local"
|
||||
aria-invalid={!!errors.dueAt}
|
||||
{...register("dueAt")}
|
||||
/>
|
||||
<FieldError errors={[errors.dueAt]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
fetchAgentProfilesAll,
|
||||
fetchAgentTeamsAll,
|
||||
type AdminAgentProfile,
|
||||
type AdminAgentTeam,
|
||||
} from "@/lib/api/admin"
|
||||
import { assignTicket, batchAssignTickets } from "@/lib/api/ticket"
|
||||
|
||||
const schema = z.object({
|
||||
toUserId: z.string().trim().min(1, "请选择处理人"),
|
||||
toTeamId: z.string().trim(),
|
||||
reason: z.string().trim(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const resolver = zodResolver(schema as never) as Resolver<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>
|
||||
|
||||
const emptyForm: FormValues = {
|
||||
toUserId: "",
|
||||
toTeamId: "",
|
||||
reason: "",
|
||||
}
|
||||
|
||||
type TicketAssignDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
ticketIds?: number[]
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketAssignDialog({
|
||||
open,
|
||||
ticketId,
|
||||
ticketIds,
|
||||
currentTeamId,
|
||||
currentAssigneeId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketAssignDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{open ? (
|
||||
<TicketAssignDialogBody
|
||||
key={ticketId ?? "ticket-assign"}
|
||||
ticketId={ticketId}
|
||||
ticketIds={ticketIds}
|
||||
currentTeamId={currentTeamId}
|
||||
currentAssigneeId={currentAssigneeId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function TicketAssignDialogBody({
|
||||
ticketId,
|
||||
ticketIds,
|
||||
currentTeamId,
|
||||
currentAssigneeId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: Omit<TicketAssignDialogProps, "open">) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
|
||||
const form = useForm<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: emptyForm,
|
||||
})
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
toUserId: currentAssigneeId ? String(currentAssigneeId) : "",
|
||||
toTeamId: currentTeamId ? String(currentTeamId) : "",
|
||||
reason: "",
|
||||
})
|
||||
}, [currentAssigneeId, currentTeamId, reset, ticketId])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([fetchAgentTeamsAll(), fetchAgentProfilesAll()])
|
||||
.then(([teamData, agentData]) => {
|
||||
setTeams(Array.isArray(teamData) ? teamData : [])
|
||||
setAgents(Array.isArray(agentData) ? agentData : [])
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "加载处理人失败")
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
const validTicketIds = (ticketIds ?? []).filter((item) => item > 0)
|
||||
if (!ticketId && validTicketIds.length === 0) {
|
||||
toast.error("请选择工单")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (validTicketIds.length > 0) {
|
||||
await batchAssignTickets({
|
||||
ticketIds: validTicketIds,
|
||||
toUserId: Number(values.toUserId),
|
||||
toTeamId: values.toTeamId ? Number(values.toTeamId) : undefined,
|
||||
reason: values.reason.trim() || undefined,
|
||||
})
|
||||
toast.success(`已批量指派 ${validTicketIds.length} 张工单`)
|
||||
} else {
|
||||
await assignTicket({
|
||||
ticketId: ticketId!,
|
||||
toUserId: Number(values.toUserId),
|
||||
toTeamId: values.toTeamId ? Number(values.toTeamId) : undefined,
|
||||
reason: values.reason.trim() || undefined,
|
||||
})
|
||||
toast.success("处理人已更新")
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "指派工单失败")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{ticketIds?.length ? `批量指派工单(${ticketIds.length})` : "指派工单"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field>
|
||||
<FieldLabel>处理团队</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="toTeamId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={loading ? "加载中..." : "选择处理团队"}
|
||||
options={[
|
||||
{ value: "", label: "不指定团队" },
|
||||
...teams.map((team) => ({
|
||||
value: String(team.id),
|
||||
label: team.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.toUserId}>
|
||||
<FieldLabel>处理人</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="toUserId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={loading ? "加载中..." : "选择处理人"}
|
||||
options={agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label:
|
||||
agent.displayName ||
|
||||
agent.nickname ||
|
||||
agent.username ||
|
||||
`客服#${agent.userId}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.toUserId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.reason}>
|
||||
<FieldLabel>说明</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea rows={4} placeholder="填写指派说明" {...register("reason")} />
|
||||
<FieldError errors={[errors.reason]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "提交中..." : "确认指派"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin"
|
||||
import { addTicketCollaborator } from "@/lib/api/ticket"
|
||||
|
||||
const schema = z.object({
|
||||
userId: z.string().trim().min(1, "请选择协作人"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const resolver = zodResolver(schema as never) as Resolver<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>
|
||||
|
||||
type TicketCollaboratorDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketCollaboratorDialog({
|
||||
open,
|
||||
ticketId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketCollaboratorDialogProps) {
|
||||
const [loadingAgents, setLoadingAgents] = useState(false)
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const userOptions = agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label: agent.displayName || agent.nickname || agent.username || `客服 #${agent.userId}`,
|
||||
}))
|
||||
|
||||
const form = useForm<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: { userId: "" },
|
||||
})
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset({ userId: "" })
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setLoadingAgents(true)
|
||||
fetchAgentProfilesAll()
|
||||
.then((data) => {
|
||||
setAgents(Array.isArray(data) ? data : [])
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingAgents(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
if (!ticketId) {
|
||||
toast.error("工单不存在")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addTicketCollaborator({ ticketId, userId: Number(values.userId) })
|
||||
toast.success("协作人已添加")
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "添加协作人失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>新增协作人</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field data-invalid={!!errors.userId}>
|
||||
<FieldLabel>协作人</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="userId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={userOptions}
|
||||
placeholder={loadingAgents ? "加载中..." : "选择协作人"}
|
||||
searchPlaceholder="搜索客服"
|
||||
emptyText="暂无可选客服"
|
||||
disabled={isSubmitting || loadingAgents}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.userId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "提交中..." : "确认添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Building2Icon,
|
||||
Link2Icon,
|
||||
MailIcon,
|
||||
PencilIcon,
|
||||
PhoneIcon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { type CustomerFormSavePayload } from "@/components/customer-form"
|
||||
import { CustomerFormDialog } from "@/components/customer-form-dialog"
|
||||
import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { updateCompany, type AdminCompany } from "@/lib/api/company"
|
||||
import {
|
||||
fetchCustomer,
|
||||
saveCustomerProfile,
|
||||
type AdminCustomer,
|
||||
} from "@/lib/api/customer"
|
||||
import {
|
||||
fetchCustomerContacts,
|
||||
type AdminCustomerContact,
|
||||
} from "@/lib/api/customer-contact"
|
||||
import { Gender, GenderLabels, ContactType, ContactTypeLabels } from "@/lib/generated/enums"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
|
||||
function contactTypeLabel(contactType: ContactType | string) {
|
||||
return ContactTypeLabels[contactType as ContactType] ?? contactType
|
||||
}
|
||||
|
||||
function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) {
|
||||
const cls = "size-3.5 shrink-0 text-muted-foreground"
|
||||
switch (contactType) {
|
||||
case ContactType.Mobile:
|
||||
return <PhoneIcon className={cls} aria-hidden />
|
||||
case ContactType.Email:
|
||||
return <MailIcon className={cls} aria-hidden />
|
||||
default:
|
||||
return <Link2Icon className={cls} aria-hidden />
|
||||
}
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
valueClassName?: string
|
||||
}) {
|
||||
const empty = !value.trim()
|
||||
return (
|
||||
<div className="flex gap-2.5 text-sm leading-snug">
|
||||
<span className="w-17 shrink-0 pt-px text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 break-all text-foreground",
|
||||
empty && "text-muted-foreground",
|
||||
valueClassName,
|
||||
)}
|
||||
>
|
||||
{empty ? "—" : value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeading({
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">{children}</h3>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnlinkedCustomerEmpty({
|
||||
ticketId,
|
||||
onSuccess,
|
||||
}: {
|
||||
ticketId: number
|
||||
onSuccess: () => void | Promise<void>
|
||||
}) {
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
|
||||
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
|
||||
<p className="text-sm font-medium text-foreground">尚未关联 CRM 客户</p>
|
||||
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
|
||||
当前工单未绑定客户主档。绑定后可在此查看客户资料、公司信息与联系方式。
|
||||
</p>
|
||||
<Button type="button" className="mt-4 gap-2" onClick={() => setLinkDialogOpen(true)}>
|
||||
<Link2Icon className="size-4" />
|
||||
关联或创建客户
|
||||
</Button>
|
||||
</div>
|
||||
<CustomerLinkOrCreateDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
ticketId={ticketId}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MissingCustomerEmpty({
|
||||
ticketId,
|
||||
onSuccess,
|
||||
}: {
|
||||
ticketId: number
|
||||
onSuccess: () => void | Promise<void>
|
||||
}) {
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
|
||||
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
|
||||
<p className="text-sm font-medium text-foreground">客户已删除或不存在</p>
|
||||
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
|
||||
当前工单绑定的客户主档已不可用。你可以重新关联已有客户,或直接新建一个客户并绑定到当前工单。
|
||||
</p>
|
||||
<Button type="button" className="mt-4 gap-2" onClick={() => setLinkDialogOpen(true)}>
|
||||
<Link2Icon className="size-4" />
|
||||
重新关联或创建客户
|
||||
</Button>
|
||||
</div>
|
||||
<CustomerLinkOrCreateDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
ticketId={ticketId}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TicketCustomerPanelProps = {
|
||||
ticketId: number
|
||||
customerId?: number
|
||||
onRefresh: () => void | Promise<void>
|
||||
}
|
||||
|
||||
type TicketLinkedCustomerPanelProps = {
|
||||
ticketId: number
|
||||
customerId: number
|
||||
onRefresh: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function TicketCustomerPanel({
|
||||
ticketId,
|
||||
customerId = 0,
|
||||
onRefresh,
|
||||
}: TicketCustomerPanelProps) {
|
||||
if (customerId <= 0) {
|
||||
return <UnlinkedCustomerEmpty ticketId={ticketId} onSuccess={onRefresh} />
|
||||
}
|
||||
return (
|
||||
<TicketLinkedCustomerPanel
|
||||
ticketId={ticketId}
|
||||
customerId={customerId}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TicketLinkedCustomerPanel({
|
||||
ticketId,
|
||||
customerId,
|
||||
onRefresh,
|
||||
}: TicketLinkedCustomerPanelProps) {
|
||||
const linkedCustomerId = customerId
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [customer, setCustomer] = useState<AdminCustomer | null>(null)
|
||||
const [contacts, setContacts] = useState<AdminCustomerContact[]>([])
|
||||
|
||||
const [customerEditOpen, setCustomerEditOpen] = useState(false)
|
||||
const [customerEditSaving, setCustomerEditSaving] = useState(false)
|
||||
const [companyEditOpen, setCompanyEditOpen] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const c = await fetchCustomer(linkedCustomerId)
|
||||
setCustomer(c)
|
||||
if (!c) {
|
||||
setContacts([])
|
||||
return
|
||||
}
|
||||
const list = await fetchCustomerContacts(linkedCustomerId)
|
||||
setContacts(Array.isArray(list) ? list : [])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载客户信息失败")
|
||||
setCustomer(null)
|
||||
setContacts([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [linkedCustomerId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
if (loading && !customer) {
|
||||
return <p className="pt-4 text-sm text-muted-foreground">加载客户信息…</p>
|
||||
}
|
||||
|
||||
if (!customer) {
|
||||
return <MissingCustomerEmpty ticketId={ticketId} onSuccess={onRefresh} />
|
||||
}
|
||||
|
||||
const displayName = customer.name.trim() || "未填写姓名"
|
||||
const company = customer.company ?? null
|
||||
const genderLabel =
|
||||
customer.gender === Gender.Male || customer.gender === Gender.Female
|
||||
? GenderLabels[customer.gender as Gender] ?? String(customer.gender)
|
||||
: null
|
||||
const isProfileEmpty =
|
||||
!customer.name.trim() &&
|
||||
!customer.primaryMobile.trim() &&
|
||||
!customer.primaryEmail.trim() &&
|
||||
customer.companyId === 0 &&
|
||||
!customer.remark.trim()
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
{isProfileEmpty ? (
|
||||
<div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-relaxed text-amber-950 dark:text-amber-100">
|
||||
客户主档已关联,但基础信息尚未填写。请点击「编辑」补全资料。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<SectionHeading
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1 px-2 text-xs"
|
||||
onClick={() => setCustomerEditOpen(true)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
客户信息
|
||||
</SectionHeading>
|
||||
<div className="flex min-w-0 items-start gap-2 text-sm">
|
||||
<UserRoundIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="line-clamp-2 leading-snug text-foreground">
|
||||
<span className="font-medium">{displayName}</span>
|
||||
{genderLabel ? (
|
||||
<span className="font-normal text-muted-foreground"> · {genderLabel}</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DetailRow label="手机" value={customer.primaryMobile || ""} />
|
||||
<DetailRow label="邮箱" value={customer.primaryEmail || ""} />
|
||||
<DetailRow
|
||||
label="最近活跃"
|
||||
value={customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""}
|
||||
/>
|
||||
<DetailRow
|
||||
label="备注"
|
||||
value={customer.remark.trim() ? customer.remark : ""}
|
||||
valueClassName="whitespace-pre-wrap"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<SectionHeading>联系方式</SectionHeading>
|
||||
{contacts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无联系方式</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{contacts.map((row) => {
|
||||
const tags: string[] = []
|
||||
if (row.isPrimary) tags.push("主")
|
||||
if (row.isVerified) tags.push("已验证")
|
||||
return (
|
||||
<li key={row.id} className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ContactTypeIcon contactType={row.contactType} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-all font-medium leading-snug text-foreground">
|
||||
{row.contactValue}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{contactTypeLabel(row.contactType)}
|
||||
</span>
|
||||
{tags.length > 0 ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{tags.join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{row.remark ? (
|
||||
<p className="mt-1 line-clamp-3 break-all text-xs leading-relaxed text-muted-foreground">
|
||||
{row.remark}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 border-t pt-2">
|
||||
<SectionHeading
|
||||
action={
|
||||
company ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1 px-2 text-xs"
|
||||
onClick={() => setCompanyEditOpen(true)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
公司信息
|
||||
</SectionHeading>
|
||||
{company ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-w-0 items-start gap-2 text-sm">
|
||||
<Building2Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="line-clamp-2 font-medium leading-snug text-foreground">
|
||||
{company.name}
|
||||
</p>
|
||||
{company.code ? (
|
||||
<p className="font-mono text-xs text-muted-foreground">{company.code}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 pt-1">
|
||||
<DetailRow label="创建" value={formatDateTime(company.createdAt)} />
|
||||
<DetailRow label="更新" value={formatDateTime(company.updatedAt)} />
|
||||
<DetailRow
|
||||
label="备注"
|
||||
value={company.remark.trim() ? company.remark : ""}
|
||||
valueClassName="whitespace-pre-wrap"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
未关联公司。可通过编辑客户资料补充公司信息。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<CustomerFormDialog
|
||||
open={customerEditOpen}
|
||||
onOpenChange={setCustomerEditOpen}
|
||||
saving={customerEditSaving}
|
||||
itemId={customer.id}
|
||||
onSave={async (payload: CustomerFormSavePayload) => {
|
||||
if (customerEditSaving) {
|
||||
return
|
||||
}
|
||||
setCustomerEditSaving(true)
|
||||
try {
|
||||
await saveCustomerProfile({ ...payload, id: customer.id })
|
||||
toast.success("已保存")
|
||||
await load()
|
||||
await onRefresh()
|
||||
setCustomerEditOpen(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败")
|
||||
} finally {
|
||||
setCustomerEditSaving(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{company ? (
|
||||
<CompanyEditDialog
|
||||
open={companyEditOpen}
|
||||
onOpenChange={setCompanyEditOpen}
|
||||
company={company}
|
||||
onSaved={async () => {
|
||||
await load()
|
||||
await onRefresh()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CompanyEditDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
company: AdminCompany
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
function CompanyEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
company,
|
||||
onSaved,
|
||||
}: CompanyEditDialogProps) {
|
||||
const [name, setName] = useState("")
|
||||
const [code, setCode] = useState("")
|
||||
const [remark, setRemark] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setName(company.name)
|
||||
setCode(company.code)
|
||||
setRemark(company.remark)
|
||||
}, [open, company])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
toast.error("公司名称不能为空")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateCompany({
|
||||
id: company.id,
|
||||
name: trimmedName,
|
||||
code: code.trim(),
|
||||
remark: remark.trim(),
|
||||
})
|
||||
toast.success("已保存")
|
||||
await onSaved()
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑公司</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-1">
|
||||
<Field orientation="vertical">
|
||||
<FieldLabel htmlFor="ticket-company-name">公司名称</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-company-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="vertical">
|
||||
<FieldLabel htmlFor="ticket-company-code">公司编码</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-company-code"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="vertical">
|
||||
<FieldLabel htmlFor="ticket-company-remark">备注</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="ticket-company-remark"
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { getTicketPriorityMap } from "@/lib/ticket-priority"
|
||||
|
||||
const priorityClassNameMap: Record<number, string> = {
|
||||
0: "bg-slate-500/10 text-slate-700 border-slate-500/20",
|
||||
1: "bg-blue-500/10 text-blue-700 border-blue-500/20",
|
||||
2: "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
||||
3: "bg-red-500/10 text-red-700 border-red-500/20",
|
||||
4: "bg-fuchsia-500/10 text-fuchsia-700 border-fuchsia-500/20",
|
||||
}
|
||||
|
||||
export function ticketPriorityLabel(priority: number, priorityName?: string) {
|
||||
return priorityName?.trim() || `P${priority}`
|
||||
}
|
||||
|
||||
export function TicketPriorityBadge({
|
||||
priority,
|
||||
priorityName,
|
||||
}: {
|
||||
priority: number
|
||||
priorityName?: string
|
||||
}) {
|
||||
const [priorityMap, setPriorityMap] = useState<Record<number, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
setPriorityMap(await getTicketPriorityMap())
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={priorityClassNameMap[priority] ?? priorityClassNameMap[0]}
|
||||
>
|
||||
{ticketPriorityLabel(priority, priorityName || priorityMap[priority])}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Resolver, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { closeTicket, reopenTicket } from "@/lib/api/ticket"
|
||||
|
||||
const schema = z.object({
|
||||
reason: z.string().trim().min(1, "请输入原因"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const resolver = zodResolver(schema as never) as Resolver<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>
|
||||
|
||||
type TicketReasonDialogProps = {
|
||||
open: boolean
|
||||
mode: "close" | "reopen"
|
||||
ticketId: number | null
|
||||
defaultReason?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketReasonDialog({
|
||||
open,
|
||||
mode,
|
||||
ticketId,
|
||||
defaultReason,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketReasonDialogProps) {
|
||||
const form = useForm<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: { reason: "" },
|
||||
})
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
reset({ reason: defaultReason || "" })
|
||||
}, [defaultReason, reset, ticketId, open])
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
if (!ticketId) {
|
||||
toast.error("工单不存在")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (mode === "close") {
|
||||
await closeTicket({ ticketId, closeReason: values.reason })
|
||||
toast.success("工单已关闭")
|
||||
} else {
|
||||
await reopenTicket({ ticketId, reason: values.reason })
|
||||
toast.success("工单已重开")
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : mode === "close" ? "关闭工单失败" : "重开工单失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{mode === "close" ? "关闭工单" : "重开工单"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field data-invalid={!!errors.reason}>
|
||||
<FieldLabel>{mode === "close" ? "关闭原因" : "重开原因"}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={mode === "close" ? "请输入关闭原因" : "请输入重开原因"}
|
||||
{...register("reason")}
|
||||
/>
|
||||
<FieldError errors={[errors.reason]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "提交中..." : mode === "close" ? "确认关闭" : "确认重开"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Resolver, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { addTicketRelation, fetchTickets, type TicketItem } from "@/lib/api/ticket"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const relationOptions = [
|
||||
{ value: "duplicate", label: "重复工单" },
|
||||
{ value: "related", label: "相关工单" },
|
||||
{ value: "parent", label: "父工单" },
|
||||
{ value: "child", label: "子工单" },
|
||||
]
|
||||
|
||||
const schema = z.object({
|
||||
relationType: z.string().trim().min(1, "请选择关联类型"),
|
||||
relatedTicketId: z.number().int().positive("请选择关联工单"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const resolver = zodResolver(schema as never) as Resolver<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>
|
||||
|
||||
type TicketRelationDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketRelationDialog({
|
||||
open,
|
||||
ticketId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketRelationDialogProps) {
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<TicketItem[]>([])
|
||||
|
||||
const form = useForm<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: {
|
||||
relationType: "related",
|
||||
relatedTicketId: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset({ relationType: "related", relatedTicketId: 0 })
|
||||
setKeyword("")
|
||||
setSearchResults([])
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const trimmedKeyword = keyword.trim()
|
||||
if (trimmedKeyword.length < 2) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const data = await fetchTickets({
|
||||
keyword: trimmedKeyword,
|
||||
page: 1,
|
||||
limit: 8,
|
||||
})
|
||||
const results = Array.isArray(data.results) ? data.results : []
|
||||
setSearchResults(results.filter((item) => item.id !== ticketId))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "搜索工单失败")
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 250)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [keyword, open, ticketId])
|
||||
|
||||
const selectedTicketId = watch("relatedTicketId")
|
||||
const selectedTicket =
|
||||
searchResults.find((item) => item.id === selectedTicketId) ?? null
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
if (!ticketId) {
|
||||
toast.error("工单不存在")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addTicketRelation({
|
||||
ticketId,
|
||||
relationType: values.relationType,
|
||||
relatedTicketId: values.relatedTicketId,
|
||||
})
|
||||
toast.success("关联工单已添加")
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "添加关联工单失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>新增关联工单</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field data-invalid={!!errors.relationType}>
|
||||
<FieldLabel>关联类型</FieldLabel>
|
||||
<FieldContent>
|
||||
<OptionCombobox
|
||||
value={watch("relationType")}
|
||||
options={relationOptions}
|
||||
placeholder="请选择关联类型"
|
||||
onChange={(value) => setValue("relationType", value, { shouldValidate: true })}
|
||||
/>
|
||||
<FieldError errors={[errors.relationType]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.relatedTicketId}>
|
||||
<FieldLabel>搜索并选择工单</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
value={keyword}
|
||||
placeholder="输入工单号或标题,至少 2 个字"
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value)
|
||||
setValue("relatedTicketId", 0, { shouldValidate: true })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border">
|
||||
{searching ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">搜索中...</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
searchResults.map((item) => {
|
||||
const active = item.id === selectedTicketId
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full flex-col items-start gap-1 border-b px-3 py-3 text-left last:border-b-0",
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-muted/40",
|
||||
)}
|
||||
onClick={() => setValue("relatedTicketId", item.id, { shouldValidate: true })}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">{item.ticketNo}</div>
|
||||
<div className="line-clamp-1 text-sm font-medium">{item.title}</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>状态:{item.status}</span>
|
||||
<span>处理人:{item.currentAssigneeName || "未指派"}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
{keyword.trim().length < 2 ? "输入至少 2 个字开始搜索" : "未找到匹配工单"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedTicket ? (
|
||||
<div className="rounded-lg border bg-muted/20 p-3 text-sm">
|
||||
已选中:{selectedTicket.ticketNo} / {selectedTicket.title}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<FieldError errors={[errors.relatedTicketId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "提交中..." : "确认添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { MessageSquarePlusIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin"
|
||||
import { addTicketInternalNote, replyTicket } from "@/lib/api/ticket"
|
||||
|
||||
type TicketReplyDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketReplyDialog({
|
||||
open,
|
||||
ticketId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketReplyDialogProps) {
|
||||
const [replyMode, setReplyMode] = useState<"public" | "internal">("public")
|
||||
const [replyContent, setReplyContent] = useState("")
|
||||
const [mentionUserId, setMentionUserId] = useState("")
|
||||
const [mentionedUsers, setMentionedUsers] = useState<AdminAgentProfile[]>([])
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const [loadingAgents, setLoadingAgents] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setReplyMode("public")
|
||||
setReplyContent("")
|
||||
setMentionUserId("")
|
||||
setMentionedUsers([])
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setLoadingAgents(true)
|
||||
fetchAgentProfilesAll()
|
||||
.then((data) => {
|
||||
setAgents(Array.isArray(data) ? data : [])
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingAgents(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const mentionOptions = useMemo(
|
||||
() =>
|
||||
agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label:
|
||||
agent.displayName ||
|
||||
agent.nickname ||
|
||||
agent.username ||
|
||||
`客服 #${agent.userId}`,
|
||||
})),
|
||||
[agents],
|
||||
)
|
||||
|
||||
function handleAddMentionUser() {
|
||||
const userId = Number(mentionUserId)
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
const user = agents.find((item) => item.userId === userId)
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
setMentionedUsers((current) => {
|
||||
if (current.some((item) => item.userId === user.userId)) {
|
||||
return current
|
||||
}
|
||||
return [...current, user]
|
||||
})
|
||||
setMentionUserId("")
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!ticketId) {
|
||||
toast.error("工单不存在")
|
||||
return
|
||||
}
|
||||
if (!replyContent.trim()) {
|
||||
toast.error(replyMode === "public" ? "回复内容不能为空" : "备注内容不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (replyMode === "public") {
|
||||
await replyTicket({
|
||||
ticketId,
|
||||
contentType: "text",
|
||||
content: replyContent.trim(),
|
||||
})
|
||||
toast.success("已回复客户")
|
||||
} else {
|
||||
const payload =
|
||||
mentionedUsers.length > 0
|
||||
? JSON.stringify({
|
||||
mentionUserIds: mentionedUsers.map((item) => item.userId),
|
||||
})
|
||||
: undefined
|
||||
await addTicketInternalNote({
|
||||
ticketId,
|
||||
contentType: "text",
|
||||
content: replyContent.trim(),
|
||||
payload,
|
||||
})
|
||||
toast.success("已添加内部备注")
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "提交失败")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-2xl">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>回复与备注</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={replyMode === "public" ? "default" : "outline"}
|
||||
onClick={() => setReplyMode("public")}
|
||||
disabled={submitting}
|
||||
>
|
||||
回复客户
|
||||
</Button>
|
||||
<Button
|
||||
variant={replyMode === "internal" ? "default" : "outline"}
|
||||
onClick={() => setReplyMode("internal")}
|
||||
disabled={submitting}
|
||||
>
|
||||
内部备注
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={replyContent}
|
||||
placeholder={replyMode === "public" ? "输入给客户的回复内容" : "输入内部备注"}
|
||||
disabled={submitting}
|
||||
onChange={(event) => setReplyContent(event.target.value)}
|
||||
/>
|
||||
|
||||
{replyMode === "internal" ? (
|
||||
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-sm font-medium">@提及协作人</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<OptionCombobox
|
||||
value={mentionUserId}
|
||||
options={mentionOptions}
|
||||
placeholder={loadingAgents ? "加载中..." : "选择要提及的客服"}
|
||||
searchPlaceholder="搜索客服"
|
||||
emptyText="暂无可选客服"
|
||||
disabled={submitting || loadingAgents}
|
||||
onChange={setMentionUserId}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting || loadingAgents}
|
||||
onClick={handleAddMentionUser}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{mentionedUsers.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mentionedUsers.map((user) => (
|
||||
<button
|
||||
key={user.userId}
|
||||
type="button"
|
||||
className="rounded-full border px-3 py-1 text-xs"
|
||||
onClick={() =>
|
||||
setMentionedUsers((current) =>
|
||||
current.filter((item) => item.userId !== user.userId),
|
||||
)
|
||||
}
|
||||
>
|
||||
@
|
||||
{user.displayName ||
|
||||
user.nickname ||
|
||||
user.username ||
|
||||
`客服#${user.userId}`}{" "}
|
||||
×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">未添加提及对象</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" disabled={submitting} onClick={() => void handleSubmit()}>
|
||||
<MessageSquarePlusIcon className="size-4" />
|
||||
{submitting ? "提交中..." : replyMode === "public" ? "发送回复" : "保存备注"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import type { TicketItem } from "@/lib/api/ticket"
|
||||
|
||||
function isClosedStatus(status: string) {
|
||||
return status === "resolved" || status === "closed" || status === "cancelled"
|
||||
}
|
||||
|
||||
export function TicketSLABadge({ ticket }: { ticket: TicketItem }) {
|
||||
if (isClosedStatus(ticket.status)) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
||||
已结束
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (!ticket.resolveDeadlineAt) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
||||
未设置
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const deadline = new Date(ticket.resolveDeadlineAt.replace(" ", "T"))
|
||||
if (Number.isNaN(deadline.getTime())) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
||||
未设置
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const remainingMinutes = Math.floor((deadline.getTime() - Date.now()) / 60000)
|
||||
if (remainingMinutes < 0) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/20 bg-red-500/10 text-red-700">
|
||||
已超时
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (remainingMinutes <= 60) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/20 bg-red-500/10 text-red-700">
|
||||
1 小时内
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (remainingMinutes <= 240) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-500/20 bg-amber-500/10 text-amber-700">
|
||||
今日风险
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/20 bg-emerald-500/10 text-emerald-700">
|
||||
正常
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
new: "新建",
|
||||
open: "处理中",
|
||||
pending_customer: "待客户反馈",
|
||||
pending_internal: "待内部处理",
|
||||
resolved: "已解决",
|
||||
closed: "已关闭",
|
||||
cancelled: "已取消",
|
||||
}
|
||||
|
||||
const statusClassNameMap: Record<string, string> = {
|
||||
new: "bg-sky-500/10 text-sky-700 border-sky-500/20",
|
||||
open: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20",
|
||||
pending_customer: "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
||||
pending_internal: "bg-orange-500/10 text-orange-700 border-orange-500/20",
|
||||
resolved: "bg-lime-500/10 text-lime-700 border-lime-500/20",
|
||||
closed: "bg-muted text-muted-foreground border-border",
|
||||
cancelled: "bg-rose-500/10 text-rose-700 border-rose-500/20",
|
||||
}
|
||||
|
||||
export function ticketStatusLabel(status: string) {
|
||||
return statusLabelMap[status] ?? status
|
||||
}
|
||||
|
||||
export function TicketStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className={statusClassNameMap[status] ?? statusClassNameMap.closed}>
|
||||
{ticketStatusLabel(status)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Settings2Icon } from "lucide-react"
|
||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
fetchTicketResolutionCodesAll,
|
||||
type TicketResolutionCode,
|
||||
} from "@/lib/api/ticket-config"
|
||||
import { batchChangeTicketStatus, changeTicketStatus } from "@/lib/api/ticket"
|
||||
|
||||
const schema = z.object({
|
||||
status: z.string().trim().min(1, "请选择状态"),
|
||||
pendingReason: z.string().trim(),
|
||||
closeReason: z.string().trim(),
|
||||
resolutionCode: z.string().trim(),
|
||||
resolutionSummary: z.string().trim(),
|
||||
reason: z.string().trim(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const resolver = zodResolver(schema as never) as Resolver<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>
|
||||
|
||||
type TicketStatusDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
ticketIds?: number[]
|
||||
currentStatus?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketStatusDialog({
|
||||
open,
|
||||
ticketId,
|
||||
ticketIds,
|
||||
currentStatus,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketStatusDialogProps) {
|
||||
const form = useForm<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: {
|
||||
status: "",
|
||||
pendingReason: "",
|
||||
closeReason: "",
|
||||
resolutionCode: "",
|
||||
resolutionSummary: "",
|
||||
reason: "",
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = form
|
||||
const [resolutionCodes, setResolutionCodes] = useState<TicketResolutionCode[]>([])
|
||||
|
||||
const targetStatus = watch("status")
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
status: currentStatus || "",
|
||||
pendingReason: "",
|
||||
closeReason: "",
|
||||
resolutionCode: "",
|
||||
resolutionSummary: "",
|
||||
reason: "",
|
||||
})
|
||||
}, [currentStatus, reset, ticketId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await fetchTicketResolutionCodesAll()
|
||||
setResolutionCodes(Array.isArray(data) ? data : [])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载解决码失败")
|
||||
}
|
||||
})()
|
||||
}, [open])
|
||||
|
||||
const resolutionCodeOptions = resolutionCodes.map((item) => ({
|
||||
value: item.code,
|
||||
label: item.name,
|
||||
}))
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
const validTicketIds = (ticketIds ?? []).filter((item) => item > 0)
|
||||
if (!ticketId && validTicketIds.length === 0) {
|
||||
toast.error("请选择工单")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (validTicketIds.length > 0) {
|
||||
await batchChangeTicketStatus({
|
||||
ticketIds: validTicketIds,
|
||||
status: values.status,
|
||||
pendingReason: values.pendingReason || undefined,
|
||||
closeReason: values.status === "closed" ? values.closeReason || undefined : undefined,
|
||||
resolutionCode: values.resolutionCode || undefined,
|
||||
resolutionSummary: values.resolutionSummary || undefined,
|
||||
reason: values.reason || undefined,
|
||||
})
|
||||
toast.success(`已批量更新 ${validTicketIds.length} 张工单`)
|
||||
} else {
|
||||
await changeTicketStatus({
|
||||
ticketId: ticketId!,
|
||||
status: values.status,
|
||||
pendingReason: values.pendingReason || undefined,
|
||||
closeReason: values.status === "closed" ? values.closeReason || undefined : undefined,
|
||||
resolutionCode: values.resolutionCode || undefined,
|
||||
resolutionSummary: values.resolutionSummary || undefined,
|
||||
reason: values.reason || undefined,
|
||||
})
|
||||
toast.success("状态已更新")
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "更新状态失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{ticketIds?.length ? `批量变更状态(${ticketIds.length})` : "变更工单状态"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field data-invalid={!!errors.status}>
|
||||
<FieldLabel>目标状态</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择状态"
|
||||
options={[
|
||||
{ value: "new", label: "新建" },
|
||||
{ value: "open", label: "处理中" },
|
||||
{ value: "pending_customer", label: "待客户反馈" },
|
||||
{ value: "pending_internal", label: "待内部处理" },
|
||||
{ value: "resolved", label: "已解决" },
|
||||
{ value: "closed", label: "已关闭" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.status]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{(targetStatus === "pending_customer" ||
|
||||
targetStatus === "pending_internal") && (
|
||||
<Field data-invalid={!!errors.pendingReason}>
|
||||
<FieldLabel>挂起原因</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea rows={3} placeholder="请输入待处理原因" {...register("pendingReason")} />
|
||||
<FieldError errors={[errors.pendingReason]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{targetStatus === "resolved" && (
|
||||
<>
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel>解决编码</FieldLabel>
|
||||
</div>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="resolutionCode"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择解决编码"
|
||||
options={resolutionCodeOptions}
|
||||
emptyText="暂无可选解决码"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{resolutionCodeOptions.length === 0 ? (
|
||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50/70 p-3 text-xs text-amber-900">
|
||||
当前没有可用解决码,解决结果无法标准化统计。
|
||||
<Link
|
||||
href="/dashboard/ticket-resolution-codes"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-1 font-medium underline underline-offset-4"
|
||||
>
|
||||
前往配置解决码
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>解决说明</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea rows={3} placeholder="请输入解决说明" {...register("resolutionSummary")} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{targetStatus === "closed" && (
|
||||
<Field>
|
||||
<FieldLabel>关闭原因</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea rows={3} placeholder="请输入关闭原因" {...register("closeReason")} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel>操作说明</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={targetStatus === "closed" ? "可补充本次批量关闭说明" : "填写本次状态变更说明"}
|
||||
{...register("reason")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "提交中..." : "确认变更"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user