276 lines
8.7 KiB
TypeScript
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>
|
|
)
|
|
}
|