fix(ticket): align lightweight frontend API

This commit is contained in:
mlogclub
2026-05-02 19:53:18 +08:00
parent a691c8d716
commit 5ccee6f714
5 changed files with 9 additions and 566 deletions
@@ -62,7 +62,8 @@ func (c *TicketController) AnySummary() *web.JsonResult {
if err != nil {
return web.JsonError(err)
}
return web.JsonData(builders.BuildTicketSummary(services.TicketService.GetSummary(operator)))
staleHours, _ := params.GetInt(c.Ctx, "staleHours")
return web.JsonData(builders.BuildTicketSummary(services.TicketService.GetSummary(operator, staleHours)))
}
func (c *TicketController) AnyView_list() *web.JsonResult {
@@ -1,6 +1,4 @@
"use client";
import Link from "next/link";
import {
Building2Icon,
Link2Icon,
@@ -626,10 +624,9 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
) : tickets.length > 0 ? (
<div className="space-y-2">
{tickets.map((ticket) => (
<Link
<div
key={ticket.id}
href={`/dashboard/tickets?ticketId=${ticket.id}`}
className="block rounded-lg border border-border bg-background px-3 py-2 transition-colors hover:bg-muted/40"
className="rounded-lg border border-border bg-background px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
@@ -647,7 +644,7 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "—"}
</span>
</div>
</Link>
</div>
))}
</div>
) : (
@@ -1,529 +0,0 @@
"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>
)
}
@@ -15,8 +15,6 @@ export type CustomerLinkOrCreateDialogProps = {
onOpenChange: (open: boolean) => void
/** 传入时会话侧:关联已有或新建后绑定该会话 */
conversationId?: number | null
/** 传入时工单侧:关联已有或新建后绑定该工单 */
ticketId?: number | null
/** 绑定成功或仅新建成功后的回调 */
onSuccess?: () => void | Promise<void>
}
@@ -27,7 +25,6 @@ export function CustomerLinkOrCreateDialog({
open,
onOpenChange,
conversationId,
ticketId,
onSuccess,
}: CustomerLinkOrCreateDialogProps) {
const [searchText, setSearchText] = useState("")
@@ -73,7 +70,7 @@ export function CustomerLinkOrCreateDialog({
}
const handleLinkExisting = async (customer: AdminCustomer) => {
if (!conversationId && !ticketId) {
if (!conversationId) {
toast.success(`已选择客户:${customer.name || `#${customer.id}`}`)
onOpenChange(false)
await onSuccess?.()
@@ -86,9 +83,6 @@ export function CustomerLinkOrCreateDialog({
conversationId,
customerId: customer.id,
})
} else if (ticketId) {
toast.error("轻量工单暂不支持在此关联客户")
return
}
toast.success("已关联客户")
onOpenChange(false)
@@ -110,8 +104,6 @@ export function CustomerLinkOrCreateDialog({
customerId: created.id,
})
toast.success("已创建客户并关联当前会话")
} else if (ticketId) {
toast.success("已创建客户")
} else {
toast.success("已创建客户")
}
@@ -129,15 +121,11 @@ export function CustomerLinkOrCreateDialog({
{conversationId
? "选中即可关联当前会话。"
: ticketId
? "选中即可关联当前工单。"
: "未接入上下文时仅创建或定位客户。"}
: "未接入上下文时仅创建或定位客户。"}
{conversationId
? ",保存后将自动关联会话。"
: ticketId
? ",保存后将自动关联工单。"
: "。"}
: "。"}
</>
)
@@ -164,8 +152,6 @@ export function CustomerLinkOrCreateDialog({
? "提交中…"
: conversationId
? "创建并关联会话"
: ticketId
? "创建并关联工单"
: "创建客户"}
</Button>
) : null}
@@ -230,8 +216,6 @@ export function CustomerLinkOrCreateDialog({
? "处理中…"
: conversationId
? "关联"
: ticketId
? "关联"
: "选用"}
</Button>
</li>
+1 -11
View File
@@ -206,21 +206,11 @@ export function changeTicketStatus(payload: {
})
}
export function fetchTicketProgresses(query: {
ticketId: number
page?: number
limit?: number
}) {
return request<PageResult<TicketProgress>>(
`/api/dashboard/ticket/progress/list${toQueryString(query)}`,
)
}
export function createTicketProgress(payload: {
ticketId: number
content: string
}) {
return request<TicketProgress>("/api/dashboard/ticket/progress/create", {
return request<TicketProgress>("/api/dashboard/ticket/add_progress", {
method: "POST",
body: JSON.stringify(payload),
})