Files
hfb_jysh/frontend/src/features/admin/AdminDisputesPage.tsx
T

276 lines
8.7 KiB
TypeScript

import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
import { fetchAdminDisputes } from '@/features/admin/api'
import { AdminDisputeDetailDrawer } from '@/features/admin/AdminDisputeDetailDrawer'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
disputeStatusMeta,
disputeStatusOptions,
disputeTypeLabel,
disputeTypeOptions,
} from '@/features/disputes/labels'
import { orderStatusMap, readStatusMeta } from '@/features/admin/labels'
import type { AdminDispute, AdminDisputeQuery } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatText } from '@/shared/utils/format'
const emptyFilters: AdminDisputeQuery = {
keyword: '',
status: '',
type: '',
}
function initiatorLabel(row: AdminDispute) {
if (row.initiator_type === 'admin') {
return `客服 #${row.initiator_admin_id ?? row.initiator_id}`
}
if (row.initiator_type === 'owner') return `号主 #${row.initiator_id}`
return `租客 #${row.initiator_id}`
}
function buildColumns(
onOpen: (row: AdminDispute) => void,
canView: boolean
): TableColumnsType<AdminDispute> {
return [
{
title: '申诉',
dataIndex: 'id',
width: 170,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
{canView ? (
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
申诉 #{row.id}
</button>
) : (
<strong>申诉 #{row.id}</strong>
)}
<span>{disputeTypeLabel(row.type)}</span>
<StatusTag meta={disputeStatusMeta(row.status)} />
</div>
),
},
{
title: '订单',
key: 'order',
width: 230,
render: (_, row) => (
<div className="admin-stack">
<span className="admin-mono">{row.order_no}</span>
<strong>{row.title}</strong>
<span>商品 {formatText(row.listing_no)}</span>
<StatusTag meta={readStatusMeta(orderStatusMap, row.order_status)} />
</div>
),
},
{
title: '双方',
key: 'parties',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>号主 {formatText(row.owner_phone)}</span>
<span>租客 {formatText(row.renter_phone)}</span>
</div>
),
},
{
title: '发起方',
key: 'initiator',
width: 130,
render: (_, row) => (
<div className="admin-stack">
<span>{initiatorLabel(row)}</span>
<span>目标用户 #{row.target_user_id}</span>
</div>
),
},
{
title: '诉求描述',
dataIndex: 'description',
width: 260,
ellipsis: true,
render: value => <Typography.Text type="secondary">{formatText(value)}</Typography.Text>,
},
{
title: '提交时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 96,
fixed: 'right',
render: (_, row) =>
canView ? (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
处理
</Button>
) : (
<Typography.Text type="secondary">无权限</Typography.Text>
),
},
]
}
export function AdminDisputesPage() {
const [draft, setDraft] = useState<AdminDisputeQuery>(emptyFilters)
const [applied, setApplied] = useState<AdminDisputeQuery>(emptyFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [activeDispute, setActiveDispute] = useState<AdminDispute | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('dispute:view'))
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
queryKey: ['admin-disputes', applied, page, pageSize],
queryFn: () => fetchAdminDisputes({ ...applied, page, page_size: pageSize }),
placeholderData: keepPreviousData,
})
const openDetail = (row: AdminDispute) => {
if (!canView) return
setActiveDispute(row)
setDetailOpen(true)
}
const columns = useMemo(() => buildColumns(openDetail, canView), [canView])
const patchFilter = (patch: Partial<AdminDisputeQuery>) => {
setDraft(current => ({ ...current, ...patch }))
}
const applyFilters = () => {
setApplied({ ...draft, keyword: draft.keyword?.trim() ?? '' })
setPage(1)
}
const resetFilters = () => {
setDraft(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}
const hasActiveFilters =
Boolean(draft.keyword?.trim()) || Boolean(draft.status) || Boolean(draft.type)
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
const pendingCount = (data?.items ?? []).filter(item =>
['open', 'processing'].includes(item.status)
).length
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 交易"
title="仲裁中心"
description="核对申诉描述、证据与结账快照,给出可追溯的仲裁结果。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {data?.total ?? 0} 条</Tag>
{pendingCount > 0 ? <Tag color="gold">本页待处理 {pendingCount}</Tag> : null}
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
刷新数据
</Button>
}
/>
<FilterBar
onSearch={applyFilters}
onReset={hasActiveFilters ? resetFilters : undefined}
loading={isFetching}
>
<FilterField label="关键词" width={300}>
<Input
allowClear
value={draft.keyword}
placeholder="订单号 / 商品编号 / 手机号"
onChange={event => patchFilter({ keyword: event.target.value })}
/>
</FilterField>
<FilterField label="申诉状态" width={150}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={disputeStatusOptions}
onChange={value => patchFilter({ status: value ?? '' })}
/>
</FilterField>
<FilterField label="申诉类型" width={180}>
<Select
allowClear
value={draft.type || undefined}
placeholder="全部类型"
options={disputeTypeOptions}
onChange={value => patchFilter({ type: value ?? '' })}
/>
</FilterField>
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="申诉列表没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminDispute>
title={<span>申诉列表</span>}
extra={
<Typography.Text type="secondary">
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
</Typography.Text>
}
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isFetching}
total={data?.total ?? 0}
page={data?.page ?? page}
pageSize={data?.page_size ?? pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
onRowClick={canView ? row => openDetail(row) : undefined}
emptyDescription="没有匹配的申诉"
/>
{canView ? (
<AdminDisputeDetailDrawer
dispute={activeDispute}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onUpdated={updated => {
setActiveDispute(updated)
void refetch()
}}
/>
) : null}
</div>
)
}