重命名前端目录
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { Button, Empty } from 'antd'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
type NotFoundPageProps = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function NotFoundPage({ title = '页面不存在' }: NotFoundPageProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<main className="empty-page">
|
||||
<Empty description={title}>
|
||||
<Button type="primary" onClick={() => navigate('/admin/dashboard')}>
|
||||
返回概览
|
||||
</Button>
|
||||
</Empty>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, DatePicker, Empty, Form, Input, Select, Space, Table, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminAuditLogs } from '@/services/admin'
|
||||
import type { AdminAuditLogItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAuditAction, formatAuditTargetType } from '@/utils/admin-display'
|
||||
import { adminAuditActionOptions, adminAuditTargetTypeOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { stringifyDisplayJson } from '@/utils/date-time'
|
||||
|
||||
type AuditFilterForm = {
|
||||
actorUsername?: string
|
||||
action?: string
|
||||
targetType?: string
|
||||
dateRange?: unknown
|
||||
}
|
||||
|
||||
export default function AdminAuditLogsPage() {
|
||||
const isAdmin = hasAdminRole('admin')
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||
const filters = {
|
||||
actorUsername: searchParams.get('actorUsername') || '',
|
||||
action: searchParams.get('action') || '',
|
||||
targetType: searchParams.get('targetType') || '',
|
||||
dateFrom: searchParams.get('dateFrom') || '',
|
||||
dateTo: searchParams.get('dateTo') || '',
|
||||
}
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
[
|
||||
filters.action,
|
||||
filters.actorUsername,
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.targetType,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-audit-logs', queryParams],
|
||||
enabled: isAdmin,
|
||||
queryFn: () => fetchAdminAuditLogs(queryParams),
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
const columns: TableColumnsType<AdminAuditLogItem> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
minWidth: 140,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{row.actorUsername || '-'}</Typography.Text>
|
||||
<StatusTag value={row.actorRole} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '动作',
|
||||
dataIndex: 'action',
|
||||
minWidth: 180,
|
||||
render: (value) => <Typography.Text strong>{formatAuditAction(value)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '目标',
|
||||
minWidth: 180,
|
||||
render: (_, row) => (
|
||||
<Typography.Text strong>
|
||||
{formatAuditTargetType(row.targetType)} #{row.targetId || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
minWidth: 260,
|
||||
render: (_, row) => <span className="payload-text">{formatPayload(row.payload)}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
function applyFilters(values: AuditFilterForm) {
|
||||
const [dateFrom, dateTo] = Array.isArray(values.dateRange)
|
||||
? values.dateRange.map((item: { format?: (pattern: string) => string }) =>
|
||||
item?.format ? item.format('YYYY-MM-DD') : '',
|
||||
)
|
||||
: ['', '']
|
||||
|
||||
setSearchParams(
|
||||
cleanParams({
|
||||
actorUsername: values.actorUsername,
|
||||
action: values.action,
|
||||
targetType: values.targetType,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: 1,
|
||||
pageSize,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="操作审计"
|
||||
description="集中查看高风险后台动作,便于排查谁在什么时间改了什么。"
|
||||
extra={<Typography.Text type="secondary">共 {data?.pagination.total || 0} 条审计记录</Typography.Text>}
|
||||
/>
|
||||
|
||||
{!isAdmin ? (
|
||||
<Alert type="warning" showIcon message="仅管理员可以访问操作审计" />
|
||||
) : (
|
||||
<>
|
||||
<Card title="筛选审计日志">
|
||||
<Form<AuditFilterForm>
|
||||
layout="inline"
|
||||
initialValues={{
|
||||
actorUsername: filters.actorUsername,
|
||||
action: filters.action,
|
||||
targetType: filters.targetType,
|
||||
dateRange:
|
||||
filters.dateFrom && filters.dateTo
|
||||
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
|
||||
: undefined,
|
||||
}}
|
||||
onFinish={applyFilters}
|
||||
className="filter-form"
|
||||
>
|
||||
<Form.Item name="actorUsername">
|
||||
<Input allowClear placeholder="操作账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="action">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="动作"
|
||||
options={adminAuditActionOptions}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="targetType">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="目标类型"
|
||||
options={adminAuditTargetTypeOptions}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{query.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '读取审计日志失败'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Table<AdminAuditLogItem>
|
||||
rowKey="logId"
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
locale={{ emptyText: <Empty description="暂无审计记录" /> }}
|
||||
scroll={{ x: 980 }}
|
||||
pagination={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSearchParams(
|
||||
cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }),
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatPayload(payload: Record<string, unknown>) {
|
||||
const text = stringifyDisplayJson(payload, 0)
|
||||
return text.length > 140 ? `${text.slice(0, 140)}...` : text
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (normalized) {
|
||||
next.set(key, normalized)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
import {
|
||||
DownloadOutlined,
|
||||
FileImageOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Image as AntdImage,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminCloudtentaclesDeliveryRecords,
|
||||
fetchAdminCloudtentaclesRecordSources,
|
||||
} from '@/services/admin'
|
||||
import type { AdminCloudtentaclesDeliveryRecordItem } from '@/types/admin'
|
||||
import { getAdminToken } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type RecordSourceOption = {
|
||||
key: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
hasToken: boolean
|
||||
loggedInAt: string
|
||||
}
|
||||
|
||||
type DateRangeValue = [Dayjs | null, Dayjs | null] | null
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 100
|
||||
const PAGE_SIZE_OPTIONS = [100, 200, 500, 1000]
|
||||
|
||||
export default function AdminCloudtentaclesRecordsPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sourceLoading, setSourceLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [sourceKey, setSourceKey] = useState('')
|
||||
const [platformOrderId, setPlatformOrderId] = useState('')
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(createDefaultDateRange())
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [items, setItems] = useState<AdminCloudtentaclesDeliveryRecordItem[]>([])
|
||||
const [sourceOptions, setSourceOptions] = useState<RecordSourceOption[]>([])
|
||||
const [previewVisible, setPreviewVisible] = useState(false)
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState('')
|
||||
const [previewFileName, setPreviewFileName] = useState('')
|
||||
|
||||
const currentSourceLabel = useMemo(() => {
|
||||
const matched = sourceOptions.find((source) => source.key === sourceKey)
|
||||
return matched?.label || sourceKey || '-'
|
||||
}, [sourceKey, sourceOptions])
|
||||
|
||||
const columns: TableColumnsType<AdminCloudtentaclesDeliveryRecordItem> = [
|
||||
{
|
||||
title: '商品 / 角色',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.name || '-' }}>
|
||||
{row.name || '-'}
|
||||
</Typography.Text>
|
||||
<span className="muted">账号:{row.fulfillUser || '-'}</span>
|
||||
{row.roleName || row.roleId ? (
|
||||
<span className="muted">
|
||||
角色:{row.roleName || '-'}
|
||||
{row.roleId ? `(${row.roleId})` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单 / 店铺',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.platformOrderId || '-' }}>
|
||||
{row.platformOrderId || '-'}
|
||||
</Typography.Text>
|
||||
<span className="muted">订单店铺:{row.orderShopName || row.orderShopId || '-'}</span>
|
||||
<span className="muted">核销店铺:{row.consumeShopName || row.consumeShopId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '虚拟号',
|
||||
minWidth: 160,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.phone || '-'}</span>
|
||||
<span className="muted">VN {row.virtualNumberId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<div className="status-stack">
|
||||
<Tag color={getStatusColor(row.status)}>{getStatusLabel(row.status)}</Tag>
|
||||
{row.localDispatchStatus ? (
|
||||
<span className="muted">{getLocalDispatchStatusLabel(row.localDispatchStatus)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
minWidth: 120,
|
||||
render: (_, row) => row.operatorName || '-',
|
||||
},
|
||||
{
|
||||
title: '发货时间',
|
||||
minWidth: 170,
|
||||
render: (_, row) => formatAdminDateTime(row.createdAt),
|
||||
},
|
||||
{
|
||||
title: '记录 ID',
|
||||
minWidth: 230,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: row.recordId || '-' }}>
|
||||
{row.recordId || '-'}
|
||||
</Typography.Text>
|
||||
{row.taskNo ? (
|
||||
<span className="muted">
|
||||
任务:{row.taskNo}
|
||||
{getMatchConfidenceLabel(row.matchConfidence)
|
||||
? ` · 匹配${getMatchConfidenceLabel(row.matchConfidence)}`
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right',
|
||||
width: 136,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<FileImageOutlined />}
|
||||
onClick={() => generateRecordImage(row)}
|
||||
>
|
||||
生成图片
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap()
|
||||
}, [])
|
||||
|
||||
async function bootstrap() {
|
||||
const nextSourceKey = await loadSources()
|
||||
if (nextSourceKey) {
|
||||
await loadRecords(1, { sourceKey: nextSourceKey })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
setSourceLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesRecordSources()
|
||||
const nextSources = response.data.sources || []
|
||||
const usableSources = nextSources.filter((source) => source.enabled && source.hasToken)
|
||||
const nextSourceKey = sourceKey || usableSources[0]?.key || nextSources[0]?.key || ''
|
||||
setSourceOptions(nextSources)
|
||||
setSourceKey(nextSourceKey)
|
||||
return nextSourceKey
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 账号失败')
|
||||
return ''
|
||||
} finally {
|
||||
setSourceLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecords(
|
||||
nextPage = page,
|
||||
options: {
|
||||
sourceKey?: string
|
||||
pageSize?: number
|
||||
platformOrderId?: string
|
||||
dateRange?: DateRangeValue
|
||||
} = {},
|
||||
) {
|
||||
const nextSourceKey = options.sourceKey ?? sourceKey
|
||||
const nextPageSize = options.pageSize ?? pageSize
|
||||
const nextDateRange = options.dateRange ?? dateRange
|
||||
const orderKeyword = (options.platformOrderId ?? platformOrderId).trim()
|
||||
|
||||
if (!nextSourceKey && !orderKeyword) {
|
||||
showError('请先选择 cloudtentacles 账号')
|
||||
return
|
||||
}
|
||||
|
||||
const [startDate, endDate] = nextDateRange || []
|
||||
if (!startDate || !endDate) {
|
||||
showError('请选择查询时间范围')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesDeliveryRecords({
|
||||
sourceKey: nextSourceKey || undefined,
|
||||
page: nextPage,
|
||||
size: nextPageSize,
|
||||
startDate: formatQueryDateTime(startDate),
|
||||
endDate: formatQueryDateTime(endDate),
|
||||
platformOrderId: orderKeyword || undefined,
|
||||
})
|
||||
|
||||
if (response.data.sourceKey && response.data.sourceKey !== sourceKey) {
|
||||
setSourceKey(response.data.sourceKey)
|
||||
}
|
||||
|
||||
setPage(response.data.page)
|
||||
setPageSize(response.data.size)
|
||||
setTotal(response.data.total)
|
||||
setItems(response.data.items || [])
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '查询发货记录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
const nextDateRange = createDefaultDateRange()
|
||||
setPlatformOrderId('')
|
||||
setDateRange(nextDateRange)
|
||||
setPage(1)
|
||||
setPageSize(DEFAULT_PAGE_SIZE)
|
||||
void loadRecords(1, {
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
platformOrderId: '',
|
||||
dateRange: nextDateRange,
|
||||
})
|
||||
}
|
||||
|
||||
async function generateRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
try {
|
||||
const dataUrl = await drawRecordImage(record)
|
||||
setPreviewImageUrl(dataUrl)
|
||||
setPreviewFileName(buildRecordImageFileName(record))
|
||||
setPreviewVisible(true)
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '生成发货记录图片失败')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPreviewImage() {
|
||||
if (!previewImageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = previewImageUrl
|
||||
link.download = previewFileName || 'cloudtentacles-delivery-record.png'
|
||||
link.click()
|
||||
showSuccess('发货记录图片已生成')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="查询发货记录"
|
||||
description="按订单号、cloudtentacles 账号和时间范围查询平台发货记录。"
|
||||
extra={<span className="total-badge">共 {total} 条记录</span>}
|
||||
/>
|
||||
|
||||
<Card
|
||||
title="查询条件"
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
输入订单号时会先关联本地任务,再匹配 cloudtentacles 发货记录。
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<div className="cloud-records-filter">
|
||||
<Select
|
||||
value={sourceKey || undefined}
|
||||
loading={sourceLoading}
|
||||
placeholder="cloudtentacles 账号"
|
||||
options={sourceOptions.map((source) => ({
|
||||
label: `${source.label || source.key}${source.hasToken ? '' : ' · 未登录'}`,
|
||||
value: source.key,
|
||||
disabled: !source.enabled || !source.hasToken,
|
||||
}))}
|
||||
onChange={setSourceKey}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={platformOrderId}
|
||||
placeholder="订单号"
|
||||
onChange={(event) => setPlatformOrderId(event.target.value)}
|
||||
onPressEnter={() => loadRecords(1)}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
value={dateRange}
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
className="full-width"
|
||||
onChange={(value) => setDateRange(value)}
|
||||
/>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={() => loadRecords(1)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<Card>
|
||||
<div className="table-toolbar">
|
||||
<strong>发货记录</strong>
|
||||
<span>
|
||||
账号 {currentSourceLabel} · 第 {page} 页,当前展示 {items.length} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Table<AdminCloudtentaclesDeliveryRecordItem>
|
||||
rowKey={(row) => row.recordId || `${row.virtualNumberId}-${row.createdAt}`}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
size="small"
|
||||
scroll={{ x: 1450 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
showSizeChanger: true,
|
||||
showTotal: (nextTotal) => `共 ${nextTotal} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
void loadRecords(nextPage, { pageSize: nextPageSize })
|
||||
},
|
||||
}}
|
||||
locale={{ emptyText: loading ? '发货记录加载中' : '当前筛选条件下暂无发货记录' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="发货记录图片"
|
||||
open={previewVisible}
|
||||
width={780}
|
||||
destroyOnHidden
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={downloadPreviewImage}
|
||||
>
|
||||
下载图片
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="record-preview">
|
||||
{previewImageUrl ? (
|
||||
<AntdImage src={previewImageUrl} alt="发货记录图片预览" preview={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusLabel(status: number) {
|
||||
if (status === 4) return '已完成'
|
||||
if (status === 1) return '处理中'
|
||||
if (status === 2) return '待处理'
|
||||
if (status === 3) return '失败'
|
||||
return `状态 ${status || '-'}`
|
||||
}
|
||||
|
||||
function getStatusColor(status: number) {
|
||||
if (status === 4) return 'green'
|
||||
if (status === 3) return 'red'
|
||||
if (status === 1) return 'orange'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function getLocalDispatchStatusLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (normalized === 'success') return '本地已发货'
|
||||
if (normalized === 'failed') return '本地发货失败'
|
||||
if (normalized === 'pending') return '本地待发货'
|
||||
return normalized || '-'
|
||||
}
|
||||
|
||||
function getMatchConfidenceLabel(value: string) {
|
||||
if (value === 'high') return '高'
|
||||
if (value === 'medium') return '中'
|
||||
if (value === 'low') return '低'
|
||||
if (value === 'local') return '本地'
|
||||
return ''
|
||||
}
|
||||
|
||||
function createDefaultDateRange(): DateRangeValue {
|
||||
const end = dayjs()
|
||||
return [end.subtract(7, 'day').startOf('day'), end]
|
||||
}
|
||||
|
||||
function formatQueryDateTime(value: Dayjs) {
|
||||
return value.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const productImage = await loadRecordProductImage(record.skuImage)
|
||||
const canvas = document.createElement('canvas')
|
||||
const scale = window.devicePixelRatio || 1
|
||||
const width = 720
|
||||
const height = 330
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('当前浏览器不支持图片生成')
|
||||
}
|
||||
|
||||
ctx.scale(scale, scale)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.fillStyle = '#1f2937'
|
||||
ctx.font = '20px sans-serif'
|
||||
ctx.fillText('订单详情', 28, 38)
|
||||
ctx.fillStyle = '#9ca3af'
|
||||
ctx.font = '24px sans-serif'
|
||||
ctx.fillText('x', width - 40, 38)
|
||||
|
||||
ctx.fillStyle = '#111827'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`购买时间:${formatAdminDateTime(record.createdAt)}`, width / 2, 88)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
drawTicketCard(ctx, record, productImage)
|
||||
drawRecordMeta(ctx, record)
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
function drawTicketCard(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
record: AdminCloudtentaclesDeliveryRecordItem,
|
||||
productImage: HTMLImageElement | null,
|
||||
) {
|
||||
const x = 170
|
||||
const y = 108
|
||||
const width = 392
|
||||
const height = 108
|
||||
const imageWidth = 106
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + 18, y)
|
||||
ctx.lineTo(x + width, y)
|
||||
ctx.lineTo(x + width, y + height - 22)
|
||||
ctx.lineTo(x + width - 18, y + height)
|
||||
ctx.lineTo(x, y + height)
|
||||
ctx.lineTo(x, y + 18)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = '#f8fbff'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#2f80d1'
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
const gradient = ctx.createLinearGradient(x, y, x + imageWidth, y + height)
|
||||
gradient.addColorStop(0, '#e7f1ff')
|
||||
gradient.addColorStop(1, '#b8d7ff')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, y, imageWidth, height)
|
||||
|
||||
if (productImage) {
|
||||
drawCoverImage(ctx, productImage, x, y, imageWidth, height)
|
||||
} else {
|
||||
ctx.fillStyle = '#355d91'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('套装发货记录', x + imageWidth / 2, y + 25)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(x + 34, y + 42, 38, 38)
|
||||
ctx.strokeStyle = '#8ab6ef'
|
||||
ctx.strokeRect(x + 34, y + 42, 38, 38)
|
||||
ctx.fillStyle = '#2f80d1'
|
||||
ctx.font = '20px sans-serif'
|
||||
ctx.fillText('GP', x + imageWidth / 2, y + 69)
|
||||
}
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
const contentX = x + imageWidth + 16
|
||||
const orderText = `订单号:${record.platformOrderId || record.recordId.slice(-8) || '-'}`
|
||||
ctx.fillStyle = '#1d5ea8'
|
||||
ctx.font = '16px sans-serif'
|
||||
drawEllipsisText(ctx, record.name || '未命名商品', contentX, y + 31, 170)
|
||||
ctx.fillStyle = '#3478c6'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(orderText, x + width - 12, y + 30)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
ctx.strokeStyle = '#c8d8ec'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(contentX, y + 42)
|
||||
ctx.lineTo(x + width - 12, y + 42)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#f59e0b'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.fillText(`购买机会 x ${record.count || 1}`, contentX, y + 66)
|
||||
ctx.fillStyle = '#16a34a'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(getStatusLabel(record.status), x + width - 12, y + 66)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
ctx.fillStyle = '#3478c6'
|
||||
ctx.font = '14px sans-serif'
|
||||
drawEllipsisText(ctx, `账号:${record.fulfillUser || record.phone || '-'}`, contentX, y + 90, 250)
|
||||
}
|
||||
|
||||
function drawRecordMeta(ctx: CanvasRenderingContext2D, record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const left = 80
|
||||
const top = 250
|
||||
const lineHeight = 22
|
||||
const values = [
|
||||
`订单店铺:${record.orderShopName || record.orderShopId || '-'}`,
|
||||
`核销店铺:${record.consumeShopName || record.consumeShopId || '-'}`,
|
||||
`绑定角色:${record.roleName || record.fulfillUser || '-'}${record.roleId ? `(${record.roleId})` : ''}`,
|
||||
]
|
||||
|
||||
ctx.fillStyle = '#374151'
|
||||
ctx.font = '13px sans-serif'
|
||||
values.forEach((value, index) => {
|
||||
drawEllipsisText(ctx, value, left, top + index * lineHeight, 560)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadRecordProductImage(imageUrl: string) {
|
||||
const normalizedUrl = String(imageUrl || '').trim()
|
||||
if (!normalizedUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const proxyUrl = `/api/v1/admin/cloudtentacles-records/image?url=${encodeURIComponent(
|
||||
normalizedUrl,
|
||||
)}`
|
||||
return loadProtectedImage(proxyUrl).catch(() => null)
|
||||
}
|
||||
|
||||
async function loadProtectedImage(src: string) {
|
||||
const token = getAdminToken()
|
||||
const response = await fetch(src, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('商品图片加载失败')
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(await response.blob())
|
||||
try {
|
||||
return await loadImage(objectUrl)
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error('商品图片加载失败'))
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
|
||||
function drawCoverImage(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const ratio = Math.max(width / image.naturalWidth, height / image.naturalHeight)
|
||||
const drawWidth = image.naturalWidth * ratio
|
||||
const drawHeight = image.naturalHeight * ratio
|
||||
const drawX = x + (width - drawWidth) / 2
|
||||
const drawY = y + (height - drawHeight) / 2
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(x, y, width, height)
|
||||
ctx.clip()
|
||||
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawEllipsisText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
) {
|
||||
let output = text
|
||||
while (output.length > 0 && ctx.measureText(output).width > maxWidth) {
|
||||
output = `${output.slice(0, -2)}...`
|
||||
}
|
||||
ctx.fillText(output || '-', x, y)
|
||||
}
|
||||
|
||||
function buildRecordImageFileName(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const name = sanitizeFileName(record.name || '发货记录')
|
||||
const id = sanitizeFileName(
|
||||
record.platformOrderId ||
|
||||
record.recordId.slice(0, 8) ||
|
||||
String(record.virtualNumberId || 'record'),
|
||||
)
|
||||
return `${name}-${id}.png`
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 48)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Alert, Card, Col, Row, Skeleton, Statistic } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { fetchAdminDashboardSummary } from '@/services/admin'
|
||||
|
||||
const cards = [
|
||||
{ key: 'todayOrders', label: '今日订单' },
|
||||
{ key: 'paidPendingClaim', label: '待领取' },
|
||||
{ key: 'claimingTasks', label: '领取中' },
|
||||
{ key: 'redeemedToday', label: '今日成功' },
|
||||
{ key: 'abnormalTasks', label: '异常任务' },
|
||||
] as const
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-dashboard-summary'],
|
||||
queryFn: () => fetchAdminDashboardSummary(),
|
||||
})
|
||||
const summary = query.data?.data
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="系统概览" description="快速查看订单和交付链路的运行状态。" />
|
||||
|
||||
{query.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '读取概览失败'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{query.isLoading ? (
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</Card>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{cards.map((card) => (
|
||||
<Col xs={24} md={12} xl={8} key={card.key}>
|
||||
<Card>
|
||||
<Statistic title={card.label} value={summary?.[card.key] ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { App, Button, Card, Form, Input, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { loginAdmin } from '@/services/admin'
|
||||
import { setAdminSession } from '@/utils/admin-auth'
|
||||
|
||||
type LoginForm = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function submitLogin(values: LoginForm) {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await loginAdmin({
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
})
|
||||
setAdminSession(response.data.token, response.data.expiresAt, response.data.user)
|
||||
message.success('登录成功')
|
||||
navigate('/admin/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '后台登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<Card className="login-panel" variant="borderless">
|
||||
<div className="login-heading">
|
||||
<Typography.Text className="eyebrow">Order Site Admin</Typography.Text>
|
||||
<Typography.Title level={1}>运营后台登录</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
使用账号密码进入后台,并按角色开放不同操作权限。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Form<LoginForm> layout="vertical" requiredMark={false} onFinish={submitLogin}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="账号"
|
||||
rules={[{ required: true, message: '请输入账号' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="输入账号" autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="输入密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录后台
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Descriptions, Skeleton, Space, Table, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminOrderDetail } from '@/services/admin'
|
||||
import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
export default function AdminOrderDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const { orderId = '' } = useParams()
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-order-detail', orderId],
|
||||
queryFn: () => fetchAdminOrderDetail(orderId),
|
||||
enabled: Boolean(orderId),
|
||||
})
|
||||
const detail = query.data?.data
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="订单详情" extra={<BackButton />} />
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.error || !detail) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="订单详情" extra={<BackButton />} />
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '订单不存在或读取失败'}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const taskColumns: TableColumnsType<AdminTaskListItem> = [
|
||||
{
|
||||
title: '任务号',
|
||||
dataIndex: 'taskNo',
|
||||
minWidth: 180,
|
||||
render: (_, row) => (
|
||||
<Typography.Link onClick={() => navigate(`/admin/tasks/${row.taskId}`)}>
|
||||
{row.taskNo}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={[0, 6]} wrap>
|
||||
<StatusTag value={row.status} kind="task" />
|
||||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||||
<StatusTag value={row.customerStatus} kind="customer" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
minWidth: 160,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.roleName || '-'}</span>
|
||||
<span className="muted">{row.roleId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '错误',
|
||||
dataIndex: 'lastError',
|
||||
minWidth: 220,
|
||||
render: (value) => <span className="muted">{value || '-'}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="订单详情"
|
||||
description={detail.order.platformOrderId}
|
||||
extra={<BackButton />}
|
||||
/>
|
||||
|
||||
<Card title="基础信息">
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="订单 ID">{detail.order.orderId}</Descriptions.Item>
|
||||
<Descriptions.Item label="店铺">
|
||||
{detail.order.shopName || detail.order.shopId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">{detail.order.platformOrderId}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单状态">
|
||||
<StatusTag value={detail.order.orderStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付状态">
|
||||
<StatusTag value={detail.order.payStatus} kind="pay" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{detail.order.totalAmount} {detail.order.currency}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{formatAdminDateTime(detail.order.createdAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{formatAdminDateTime(detail.order.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品摘要">{detail.order.itemSummary || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="商品明细">
|
||||
<Table<AdminOrderDetail['items'][number]>
|
||||
rowKey="orderItemId"
|
||||
pagination={false}
|
||||
dataSource={detail.items}
|
||||
scroll={{ x: 760 }}
|
||||
columns={[
|
||||
{ title: '商品', dataIndex: 'itemTitle', minWidth: 240 },
|
||||
{ title: 'SKU', dataIndex: 'skuCode', minWidth: 180 },
|
||||
{ title: '数量', dataIndex: 'quantity', width: 90 },
|
||||
{ title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="任务">
|
||||
<Table<AdminTaskListItem>
|
||||
rowKey="taskId"
|
||||
pagination={false}
|
||||
dataSource={detail.tasks}
|
||||
columns={taskColumns}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="原始载荷">
|
||||
<JsonPreview value={detail.order.rawPayload} />
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BackButton() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/orders')}>
|
||||
返回订单
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Form, Input, Select, Space, Table, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminOrders } from '@/services/admin'
|
||||
import type { AdminOrderListItem } from '@/types/admin'
|
||||
import { adminPayStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type OrderFilterForm = {
|
||||
platformOrderId?: string
|
||||
payStatus?: string
|
||||
skuCode?: string
|
||||
}
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||
const filters = {
|
||||
platformOrderId: searchParams.get('platformOrderId') || '',
|
||||
payStatus: searchParams.get('payStatus') || '',
|
||||
skuCode: searchParams.get('skuCode') || '',
|
||||
}
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
[filters.platformOrderId, filters.payStatus, filters.skuCode, page, pageSize],
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-orders', queryParams],
|
||||
queryFn: () => fetchAdminOrders(queryParams),
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
const columns: TableColumnsType<AdminOrderListItem> = [
|
||||
{
|
||||
title: '订单信息',
|
||||
dataIndex: 'platformOrderId',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Link onClick={() => navigate(`/admin/orders/${row.orderId}`)}>
|
||||
{row.platformOrderId}
|
||||
</Typography.Link>
|
||||
<span className="muted">{row.shopName || row.shopId || row.provider}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品 / 任务',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.itemSummary || '-'}</span>
|
||||
<span className="muted">
|
||||
商品 {row.itemCount} · 数量 {row.totalQuantity} · 任务 {row.taskCount}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={[0, 6]} wrap>
|
||||
<StatusTag value={row.payStatus} kind="pay" />
|
||||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||||
<StatusTag value={row.customerStatus} kind="customer" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额 / 时间',
|
||||
minWidth: 180,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<strong>{row.totalAmount || '0.00'} {row.currency}</strong>
|
||||
<span className="muted">{formatAdminDateTime(row.createdAt)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function applyFilters(values: OrderFilterForm) {
|
||||
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="订单" description="查看订单入库、商品匹配和任务拆分情况。" />
|
||||
|
||||
<Card>
|
||||
<Form<OrderFilterForm>
|
||||
layout="inline"
|
||||
initialValues={filters}
|
||||
onFinish={applyFilters}
|
||||
className="filter-form"
|
||||
>
|
||||
<Form.Item name="platformOrderId">
|
||||
<Input allowClear placeholder="平台订单号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="payStatus">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="支付状态"
|
||||
options={adminPayStatusOptions}
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="skuCode">
|
||||
<Input allowClear placeholder="SKU 标识" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<AdminOrderListItem>
|
||||
rowKey="orderId"
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (normalized) {
|
||||
next.set(key, normalized)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
ReloadOutlined,
|
||||
RollbackOutlined,
|
||||
SendOutlined,
|
||||
SwapOutlined,
|
||||
ToolOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
Skeleton,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
fetchAdminTaskDetail,
|
||||
fetchAdminTaskScreenshot,
|
||||
markAdminTaskManualReview,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
retryAdminTask,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import {
|
||||
formatKuaishouRoleInfoLabel,
|
||||
formatRedeemOutcomeLabel,
|
||||
formatRedeemResolutionStatus,
|
||||
formatTaskEventPayload,
|
||||
formatTaskEventType,
|
||||
} from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type TaskEvent = AdminTaskDetail['events'][number]
|
||||
type ManualDispatchForm = {
|
||||
deliveryReference: string
|
||||
deliveredCredential: string
|
||||
resultMessage: string
|
||||
}
|
||||
|
||||
export default function AdminTaskDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const { taskId = '' } = useParams()
|
||||
const { message, modal } = App.useApp()
|
||||
const [actionLoadingKey, setActionLoadingKey] = useState('')
|
||||
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||||
const [screenshotPreviewUrl, setScreenshotPreviewUrl] = useState('')
|
||||
const [manualDispatchForm, setManualDispatchForm] = useState<ManualDispatchForm>({
|
||||
deliveryReference: '',
|
||||
deliveredCredential: '',
|
||||
resultMessage: '',
|
||||
})
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-task-detail', taskId],
|
||||
queryFn: () => fetchAdminTaskDetail(taskId),
|
||||
enabled: Boolean(taskId),
|
||||
})
|
||||
const detail = query.data?.data
|
||||
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||
const canCloseTasks = hasAdminRole('support')
|
||||
const claimUrl = useMemo(() => {
|
||||
const tokenStatus = String(detail?.claimToken?.status || '').trim()
|
||||
const taskStatus = String(detail?.task.status || '').trim()
|
||||
|
||||
if (tokenStatus !== 'active' || ['closed', 'expired'].includes(taskStatus)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return lastClaimUrl || detail?.claimToken?.claimUrl || ''
|
||||
}, [detail?.claimToken?.claimUrl, detail?.claimToken?.status, detail?.task.status, lastClaimUrl])
|
||||
const claimLinkInvalid = Boolean(
|
||||
detail?.claimToken?.claimUrl &&
|
||||
String(detail.claimToken.status || '').trim() &&
|
||||
String(detail.claimToken.status || '').trim() !== 'active',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail) return
|
||||
|
||||
setManualDispatchForm({
|
||||
deliveryReference: detail.manualDispatch?.deliveryReference || '',
|
||||
deliveredCredential: detail.manualDispatch?.deliveredCredential || '',
|
||||
resultMessage: detail.manualDispatch?.resultMessage || detail.task.resultMessage || '',
|
||||
})
|
||||
}, [detail])
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = ''
|
||||
setScreenshotPreviewUrl('')
|
||||
|
||||
if (!detail?.screenshotUrl) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
fetchAdminTaskScreenshot(detail.task.taskId)
|
||||
.then((blob) => {
|
||||
objectUrl = URL.createObjectURL(blob)
|
||||
setScreenshotPreviewUrl(objectUrl)
|
||||
})
|
||||
.catch(() => {
|
||||
setScreenshotPreviewUrl('')
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
}, [detail?.screenshotUrl, detail?.task.taskId])
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="任务详情" extra={<BackButton />} />
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.error || !detail) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="任务详情" extra={<BackButton />} />
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '任务不存在或读取失败'}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedDetail = detail
|
||||
const flow = resolvedDetail.kuaishouCloudFulfillment
|
||||
const eventColumns: TableColumnsType<TaskEvent> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'eventType',
|
||||
width: 180,
|
||||
render: (value) => formatTaskEventType(value),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
render: (_, row) => formatTaskEventPayload(row.payload),
|
||||
},
|
||||
]
|
||||
|
||||
async function copyClaimUrl() {
|
||||
if (!claimUrl) return
|
||||
await navigator.clipboard.writeText(claimUrl)
|
||||
message.success('领取链接已复制')
|
||||
}
|
||||
|
||||
function runTaskAction(
|
||||
actionKey: string,
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) {
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: confirmText,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingKey(actionKey)
|
||||
try {
|
||||
const response = await action()
|
||||
if (response.data.claimUrl) {
|
||||
setLastClaimUrl(response.data.claimUrl)
|
||||
}
|
||||
message.success(successMessage)
|
||||
await query.refetch()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
setActionLoadingKey('')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitManualDispatch(outcome: 'delivered' | 'failed') {
|
||||
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
|
||||
const confirmText =
|
||||
outcome === 'failed'
|
||||
? `确认把任务 ${resolvedDetail.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
|
||||
: `确认把任务 ${resolvedDetail.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
|
||||
|
||||
runTaskAction(
|
||||
`manual-${outcome}`,
|
||||
() =>
|
||||
completeAdminTaskManualDispatch(resolvedDetail.task.taskId, {
|
||||
outcome,
|
||||
resultMessage: manualDispatchForm.resultMessage,
|
||||
deliveryReference: manualDispatchForm.deliveryReference,
|
||||
deliveredCredential: manualDispatchForm.deliveredCredential,
|
||||
}),
|
||||
actionLabel,
|
||||
confirmText,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="任务详情"
|
||||
description={resolvedDetail.task.taskNo}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
<BackButton />
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<TaskActionPanel
|
||||
detail={resolvedDetail}
|
||||
canManageTaskLifecycle={canManageTaskLifecycle}
|
||||
canCloseTasks={canCloseTasks}
|
||||
actionLoadingKey={actionLoadingKey}
|
||||
onRetry={() =>
|
||||
runTaskAction(
|
||||
'retry',
|
||||
() => retryAdminTask(resolvedDetail.task.taskId),
|
||||
'任务已重试',
|
||||
`确认重试任务 ${resolvedDetail.task.taskNo} 吗?`,
|
||||
)
|
||||
}
|
||||
onRebindRole={() =>
|
||||
runTaskAction(
|
||||
'rebind-role',
|
||||
() => rebindAdminTaskKuaishouCloudRole(resolvedDetail.task.taskId),
|
||||
'角色换绑资源已准备完成',
|
||||
`确认为任务 ${resolvedDetail.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
)
|
||||
}
|
||||
onPrepareKuaishouCloud={() =>
|
||||
runTaskAction(
|
||||
'prepare-kuaishou-cloud',
|
||||
() => prepareAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
|
||||
'绑定资源已准备完成',
|
||||
`确认开始为任务 ${resolvedDetail.task.taskNo} 准备绑定资源吗?系统会自动检查背包、余额并申请虚拟号。`,
|
||||
)
|
||||
}
|
||||
onDispatchKuaishouCloud={() =>
|
||||
runTaskAction(
|
||||
'dispatch-kuaishou-cloud',
|
||||
() => dispatchAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
|
||||
'已完成绑定确认并发货',
|
||||
`确认客户已经完成绑定,并立即为任务 ${resolvedDetail.task.taskNo} 执行发货吗?这个动作会把"确认绑定完成"和"发货"合并为一步。`,
|
||||
)
|
||||
}
|
||||
onReturnKuaishouCloud={() =>
|
||||
runTaskAction(
|
||||
'return-kuaishou-cloud',
|
||||
() => returnNumberAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
|
||||
'号码已退还',
|
||||
`确认退还任务 ${resolvedDetail.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`,
|
||||
)
|
||||
}
|
||||
onMarkManualReview={() =>
|
||||
runTaskAction(
|
||||
'manual-review',
|
||||
() => markAdminTaskManualReview(resolvedDetail.task.taskId),
|
||||
'任务已转人工处理',
|
||||
`确认将任务 ${resolvedDetail.task.taskNo} 转为人工处理吗?`,
|
||||
)
|
||||
}
|
||||
onCloseTask={() =>
|
||||
runTaskAction(
|
||||
'close',
|
||||
() => closeAdminTask(resolvedDetail.task.taskId),
|
||||
'任务已关闭',
|
||||
`确认关闭任务 ${resolvedDetail.task.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="基础信息">
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="任务 ID">{resolvedDetail.task.taskId}</Descriptions.Item>
|
||||
<Descriptions.Item label="任务号">{resolvedDetail.task.taskNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">
|
||||
{resolvedDetail.task.platformOrderId || resolvedDetail.order?.platformOrderId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务状态">
|
||||
<StatusTag value={resolvedDetail.task.status} kind="task" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源状态">
|
||||
<StatusTag value={resolvedDetail.task.resourceStatus} kind="resource" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户步骤">
|
||||
<StatusTag value={resolvedDetail.task.customerStatus} kind="customer" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品">
|
||||
{resolvedDetail.orderItem?.skuName || resolvedDetail.task.skuName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">
|
||||
{resolvedDetail.task.roleName || '-'} {resolvedDetail.task.roleId ? `(${resolvedDetail.task.roleId})` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{formatAdminDateTime(resolvedDetail.task.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近错误" span={3}>
|
||||
{resolvedDetail.task.lastError || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{resolvedDetail.claimToken ? (
|
||||
<Card title="领取链接">
|
||||
<Space direction="vertical" size={8} className="full-width">
|
||||
{claimUrl ? (
|
||||
<Typography.Text copyable={{ text: claimUrl }}>{claimUrl}</Typography.Text>
|
||||
) : claimLinkInvalid ? (
|
||||
<Typography.Text type="secondary">领取链接已失效</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无可用领取链接</Typography.Text>
|
||||
)}
|
||||
<Space wrap>
|
||||
<StatusTag value={resolvedDetail.claimToken.status} />
|
||||
<Typography.Text type="secondary">
|
||||
到期:{formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}
|
||||
</Typography.Text>
|
||||
<Button icon={<CopyOutlined />} disabled={!claimUrl} onClick={copyClaimUrl}>
|
||||
复制链接
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{resolvedDetail.task.executorKey === 'manual_dispatch' ||
|
||||
resolvedDetail.operations.canCompleteManualDispatch ||
|
||||
resolvedDetail.manualDispatch ? (
|
||||
<ManualDispatchPanel
|
||||
detail={resolvedDetail}
|
||||
form={manualDispatchForm}
|
||||
canSubmit={canManageTaskLifecycle && resolvedDetail.operations.canCompleteManualDispatch}
|
||||
actionLoadingKey={actionLoadingKey}
|
||||
onChange={setManualDispatchForm}
|
||||
onSubmit={submitManualDispatch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'cloud',
|
||||
label: '快手 Cloud',
|
||||
children: flow ? (
|
||||
<KuaishouCloudPanel
|
||||
flow={flow}
|
||||
canRefresh={resolvedDetail.operations.canRefreshKuaishouCloudRoleInfo}
|
||||
actionLoadingKey={actionLoadingKey}
|
||||
onRefreshRole={() =>
|
||||
runTaskAction(
|
||||
'refresh-role',
|
||||
() => refreshAdminTaskKuaishouCloudRoleInfo(resolvedDetail.task.taskId),
|
||||
'角色信息已刷新',
|
||||
`确认刷新任务 ${resolvedDetail.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 cloudtentacles 的绑定结果。`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="无快手 Cloud 履约上下文" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resolution',
|
||||
label: '兑换重试',
|
||||
children: resolvedDetail.redeemResolution ? (
|
||||
<RedeemResolutionPanel resolution={resolvedDetail.redeemResolution} />
|
||||
) : (
|
||||
<Empty description="无兑换重试记录" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'screenshot',
|
||||
label: '截图',
|
||||
children: resolvedDetail.screenshotUrl ? (
|
||||
<ScreenshotPanel screenshotPreviewUrl={screenshotPreviewUrl} />
|
||||
) : (
|
||||
<Empty description="当前任务没有截图" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
label: '事件流水',
|
||||
children: (
|
||||
<Table<TaskEvent>
|
||||
rowKey="eventId"
|
||||
pagination={false}
|
||||
columns={eventColumns}
|
||||
dataSource={resolvedDetail.events}
|
||||
scroll={{ x: 760 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
label: '原始数据',
|
||||
children: <JsonPreview value={resolvedDetail} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskActionPanel({
|
||||
detail,
|
||||
canManageTaskLifecycle,
|
||||
canCloseTasks,
|
||||
actionLoadingKey,
|
||||
onRetry,
|
||||
onRebindRole,
|
||||
onPrepareKuaishouCloud,
|
||||
onDispatchKuaishouCloud,
|
||||
onReturnKuaishouCloud,
|
||||
onMarkManualReview,
|
||||
onCloseTask,
|
||||
}: {
|
||||
detail: AdminTaskDetail
|
||||
canManageTaskLifecycle: boolean
|
||||
canCloseTasks: boolean
|
||||
actionLoadingKey: string
|
||||
onRetry: () => void
|
||||
onRebindRole: () => void
|
||||
onPrepareKuaishouCloud: () => void
|
||||
onDispatchKuaishouCloud: () => void
|
||||
onReturnKuaishouCloud: () => void
|
||||
onMarkManualReview: () => void
|
||||
onCloseTask: () => void
|
||||
}) {
|
||||
const operations = detail.operations
|
||||
const loading = Boolean(actionLoadingKey)
|
||||
|
||||
return (
|
||||
<Card title="任务操作">
|
||||
<Space wrap>
|
||||
{canManageTaskLifecycle ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={loading || !operations.canRetry}
|
||||
loading={actionLoadingKey === 'retry'}
|
||||
onClick={onRetry}
|
||||
>
|
||||
重试任务
|
||||
</Button>
|
||||
) : null}
|
||||
{operations.canRebindKuaishouCloudRole ? (
|
||||
<Button
|
||||
icon={<SwapOutlined />}
|
||||
disabled={loading}
|
||||
loading={actionLoadingKey === 'rebind-role'}
|
||||
onClick={onRebindRole}
|
||||
>
|
||||
换绑角色
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle && operations.canPrepareKuaishouCloudFulfillment ? (
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ToolOutlined />}
|
||||
disabled={loading}
|
||||
loading={actionLoadingKey === 'prepare-kuaishou-cloud'}
|
||||
onClick={onPrepareKuaishouCloud}
|
||||
>
|
||||
准备绑定资源
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle && operations.canDispatchKuaishouCloudFulfillment ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={loading}
|
||||
loading={actionLoadingKey === 'dispatch-kuaishou-cloud'}
|
||||
onClick={onDispatchKuaishouCloud}
|
||||
>
|
||||
确认绑定完成并发货
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle && operations.canReturnKuaishouCloudFulfillment ? (
|
||||
<Button
|
||||
icon={<RollbackOutlined />}
|
||||
disabled={loading}
|
||||
loading={actionLoadingKey === 'return-kuaishou-cloud'}
|
||||
onClick={onReturnKuaishouCloud}
|
||||
>
|
||||
退还号码
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle ? (
|
||||
<Button
|
||||
icon={<UserSwitchOutlined />}
|
||||
disabled={loading || !operations.canMarkManualReview}
|
||||
loading={actionLoadingKey === 'manual-review'}
|
||||
onClick={onMarkManualReview}
|
||||
>
|
||||
转人工
|
||||
</Button>
|
||||
) : null}
|
||||
{canCloseTasks ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
disabled={loading || !operations.canClose}
|
||||
loading={actionLoadingKey === 'close'}
|
||||
onClick={onCloseTask}
|
||||
>
|
||||
关闭任务
|
||||
</Button>
|
||||
) : null}
|
||||
{!canManageTaskLifecycle && !canCloseTasks && !operations.canRebindKuaishouCloudRole ? (
|
||||
<Typography.Text type="secondary">当前账号没有可执行的任务操作</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{loading ? (
|
||||
<Typography.Text className="task-action-hint" type="secondary">
|
||||
操作执行中,请稍候。
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ManualDispatchPanel({
|
||||
detail,
|
||||
form,
|
||||
canSubmit,
|
||||
actionLoadingKey,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
detail: AdminTaskDetail
|
||||
form: ManualDispatchForm
|
||||
canSubmit: boolean
|
||||
actionLoadingKey: string
|
||||
onChange: (form: ManualDispatchForm) => void
|
||||
onSubmit: (outcome: 'delivered' | 'failed') => void
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
title="人工履约"
|
||||
extra={
|
||||
canSubmit ? (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
disabled={Boolean(actionLoadingKey)}
|
||||
loading={actionLoadingKey === 'manual-delivered'}
|
||||
onClick={() => onSubmit('delivered')}
|
||||
>
|
||||
人工完成
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
disabled={Boolean(actionLoadingKey)}
|
||||
loading={actionLoadingKey === 'manual-failed'}
|
||||
onClick={() => onSubmit('failed')}
|
||||
>
|
||||
履约失败
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="manual-dispatch-grid">
|
||||
<div>
|
||||
<Typography.Text type="secondary">履约单号 / 回执</Typography.Text>
|
||||
<Input
|
||||
value={form.deliveryReference}
|
||||
disabled={!canSubmit || Boolean(actionLoadingKey)}
|
||||
placeholder="例如快递单号、平台消息回执号"
|
||||
onChange={(event) => onChange({ ...form, deliveryReference: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">发放凭据</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={form.deliveredCredential}
|
||||
disabled={!canSubmit || Boolean(actionLoadingKey)}
|
||||
placeholder="可填写人工发送的卡密、链接或关键信息"
|
||||
rows={3}
|
||||
onChange={(event) => onChange({ ...form, deliveredCredential: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">处理备注</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={form.resultMessage}
|
||||
disabled={!canSubmit || Boolean(actionLoadingKey)}
|
||||
placeholder="说明实际履约结果,失败时建议写清原因"
|
||||
rows={3}
|
||||
onChange={(event) => onChange({ ...form, resultMessage: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type="info"
|
||||
showIcon
|
||||
message="回写后会直接更新任务终态,不再走旧式领取链接流程。"
|
||||
/>
|
||||
|
||||
{detail.manualDispatch ? (
|
||||
<Descriptions
|
||||
className="platform-section-gap"
|
||||
column={{ xs: 1, md: 2, xl: 3 }}
|
||||
bordered
|
||||
size="small"
|
||||
>
|
||||
<Descriptions.Item label="处理结果">
|
||||
<StatusTag value={detail.manualDispatch.outcome || detail.task.deliveryStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="处理时间">
|
||||
{formatAdminDateTime(detail.manualDispatch.completedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="处理人">
|
||||
{detail.manualDispatch.completedBy?.username || '-'} /{' '}
|
||||
{detail.manualDispatch.completedBy?.role || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="履约单号">
|
||||
{detail.manualDispatch.deliveryReference || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发放凭据">
|
||||
{detail.manualDispatch.deliveredCredential || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">
|
||||
{detail.manualDispatch.resultMessage || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function KuaishouCloudPanel({
|
||||
flow,
|
||||
canRefresh,
|
||||
actionLoadingKey,
|
||||
onRefreshRole,
|
||||
}: {
|
||||
flow: NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
|
||||
canRefresh: boolean
|
||||
actionLoadingKey: string
|
||||
onRefreshRole: () => void
|
||||
}) {
|
||||
const roleInfoEntries = flow.role.rawInfo
|
||||
? Object.entries(flow.role.rawInfo).filter(
|
||||
([, value]) => value !== null && value !== undefined && String(value).trim() !== '',
|
||||
)
|
||||
: []
|
||||
const checklist = [
|
||||
{
|
||||
key: 'ticket',
|
||||
label: '核销码校验',
|
||||
status: flow.ticket.status || 'pending',
|
||||
detail: flow.ticket.verifiedAt
|
||||
? `已于 ${formatAdminDateTime(flow.ticket.verifiedAt)} 校验`
|
||||
: '等待客户提交并校验核销码',
|
||||
},
|
||||
{
|
||||
key: 'bind',
|
||||
label: '绑定资源',
|
||||
status: flow.binding.prepareStatus || 'pending',
|
||||
detail: flow.binding.bindPreparedAt
|
||||
? `绑定资源已准备,VN ${flow.binding.vnId || '-'}`
|
||||
: '等待准备 Cloud 绑定资源',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色识别',
|
||||
status: flow.role.status || 'pending',
|
||||
detail:
|
||||
flow.role.name || flow.role.rid
|
||||
? `${flow.role.name || '-'} / ${flow.role.rid || '-'}`
|
||||
: '客户绑定后刷新角色信息',
|
||||
},
|
||||
{
|
||||
key: 'dispatch',
|
||||
label: '发货执行',
|
||||
status: flow.dispatch.status || 'pending',
|
||||
detail: flow.dispatch.dispatchAt
|
||||
? `已于 ${formatAdminDateTime(flow.dispatch.dispatchAt)} 发货`
|
||||
: '等待客服确认绑定并发货',
|
||||
},
|
||||
{
|
||||
key: 'return',
|
||||
label: '退还号码',
|
||||
status: flow.returnNumber.status || 'pending',
|
||||
detail: flow.returnNumber.returnedAt
|
||||
? `已于 ${formatAdminDateTime(flow.returnNumber.returnedAt)} 退号`
|
||||
: '发货后执行退还号码',
|
||||
},
|
||||
{
|
||||
key: 'consume',
|
||||
label: '快手核销',
|
||||
status: flow.consume.status || 'pending',
|
||||
detail: flow.consume.consumedAt
|
||||
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 完成核销`
|
||||
: flow.consume.errorMessage || '等待退号后核销收口',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="kuaishou-cloud-stack">
|
||||
<Card
|
||||
title="快手 Cloud 履约"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={Boolean(actionLoadingKey) || !canRefresh}
|
||||
loading={actionLoadingKey === 'refresh-role'}
|
||||
onClick={onRefreshRole}
|
||||
>
|
||||
刷新角色
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="来源账号">
|
||||
{flow.binding.resolvedSourceLabel || flow.binding.resolvedSourceKey || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="虚拟号">
|
||||
{flow.binding.vnPhone || flow.binding.vnId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定状态">
|
||||
<StatusTag value={flow.binding.prepareStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">
|
||||
{flow.role.name || '-'} {flow.role.rid ? `(${flow.role.rid})` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卡券">
|
||||
<StatusTag value={flow.ticket.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发货">
|
||||
<StatusTag value={flow.dispatch.status} kind="delivery" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销">
|
||||
<StatusTag value={flow.consume.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="退号">
|
||||
<StatusTag value={flow.returnNumber.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定链接" span={3}>
|
||||
{flow.binding.bindUrl ? (
|
||||
<Typography.Text copyable={{ text: flow.binding.bindUrl }}>
|
||||
{flow.binding.bindUrl}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="流程检查">
|
||||
<div className="task-flow-grid">
|
||||
{checklist.map((item) => (
|
||||
<div key={item.key} className="task-flow-item">
|
||||
<Space>
|
||||
<StatusTag value={item.status} />
|
||||
<Typography.Text strong>{item.label}</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{roleInfoEntries.length > 0 ? (
|
||||
<Card title="角色原始字段">
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
{roleInfoEntries.map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={formatKuaishouRoleInfoLabel(key)}>
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{flow.rebind.history.length > 0 ? (
|
||||
<Card title="换绑记录">
|
||||
<Table
|
||||
rowKey={(row) => `${row.attempt}-${row.requestedAt || ''}`}
|
||||
pagination={false}
|
||||
dataSource={flow.rebind.history}
|
||||
scroll={{ x: 860 }}
|
||||
columns={[
|
||||
{ title: '次数', dataIndex: 'attempt', width: 80 },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'requestedAt',
|
||||
width: 180,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (value) => (
|
||||
<Tag color={value === 'success' ? 'green' : value === 'failed' ? 'red' : 'blue'}>
|
||||
{String(value || '-')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '旧角色',
|
||||
render: (_, row) =>
|
||||
`${row.oldBinding.roleName || '-'} / ${row.oldBinding.roleId || '-'}`,
|
||||
},
|
||||
{
|
||||
title: '旧虚拟号',
|
||||
render: (_, row) =>
|
||||
row.oldBinding.vnPhone || row.oldBinding.vnId || row.oldBinding.vnKey || '-',
|
||||
},
|
||||
{
|
||||
title: '异常',
|
||||
dataIndex: 'errorMessage',
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RedeemResolutionPanel({
|
||||
resolution,
|
||||
}: {
|
||||
resolution: NonNullable<AdminTaskDetail['redeemResolution']>
|
||||
}) {
|
||||
return (
|
||||
<Card title="兑换重试链路">
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 4 }} bordered size="small">
|
||||
<Descriptions.Item label="处理结果">
|
||||
{formatRedeemResolutionStatus(resolution.status)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务状态">{resolution.taskStatus || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="替换次数">{resolution.replacementCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">
|
||||
{formatAdminDateTime(resolution.finishedAt)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Table
|
||||
className="platform-section-gap"
|
||||
rowKey={(row) => `${row.attempt}-${row.codeMasked}`}
|
||||
pagination={false}
|
||||
dataSource={resolution.attempts}
|
||||
scroll={{ x: 760 }}
|
||||
columns={[
|
||||
{ title: '次数', dataIndex: 'attempt', width: 80 },
|
||||
{ title: '凭据', dataIndex: 'codeMasked', minWidth: 160 },
|
||||
{ title: '类型', dataIndex: 'credentialType', minWidth: 120 },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'outcome',
|
||||
minWidth: 160,
|
||||
render: (value) => formatRedeemOutcomeLabel(value),
|
||||
},
|
||||
{ title: '代码', dataIndex: 'resultCode', minWidth: 120 },
|
||||
{ title: '说明', dataIndex: 'resultMessage', minWidth: 220 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ScreenshotPanel({ screenshotPreviewUrl }: { screenshotPreviewUrl: string }) {
|
||||
return (
|
||||
<Card title="任务截图">
|
||||
{screenshotPreviewUrl ? (
|
||||
<div className="task-screenshot-preview">
|
||||
<Image src={screenshotPreviewUrl} alt="任务截图" />
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="截图加载中或当前不可读取" />
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function BackButton() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/tasks')}>
|
||||
返回任务
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SwapOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Alert, App, Button, Card, DatePicker, Form, Input, Select, Space, Table, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import {
|
||||
closeAdminTask,
|
||||
fetchAdminTasks,
|
||||
markAdminTaskManualReview,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
retryAdminTask,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminTaskStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type TaskFilterForm = {
|
||||
status?: string
|
||||
platformOrderId?: string
|
||||
taskNo?: string
|
||||
skuCode?: string
|
||||
roleId?: string
|
||||
dateRange?: unknown
|
||||
}
|
||||
|
||||
type TaskListAction = 'retry' | 'rebind_role' | 'manual_review' | 'close'
|
||||
|
||||
export default function AdminTasksPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message, modal } = App.useApp()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||
const canCloseTasks = hasAdminRole('support')
|
||||
const filters = {
|
||||
status: searchParams.get('status') || '',
|
||||
platformOrderId: searchParams.get('platformOrderId') || '',
|
||||
taskNo: searchParams.get('taskNo') || '',
|
||||
skuCode: searchParams.get('skuCode') || '',
|
||||
roleId: searchParams.get('roleId') || '',
|
||||
dateFrom: searchParams.get('dateFrom') || '',
|
||||
dateTo: searchParams.get('dateTo') || '',
|
||||
}
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
[
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.platformOrderId,
|
||||
filters.roleId,
|
||||
filters.skuCode,
|
||||
filters.status,
|
||||
filters.taskNo,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-tasks', queryParams],
|
||||
queryFn: () => fetchAdminTasks(queryParams),
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
const columns: TableColumnsType<AdminTaskListItem> = [
|
||||
{
|
||||
title: '任务 / 订单',
|
||||
minWidth: 230,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Link onClick={() => navigate(`/admin/tasks/${row.taskId}`)}>
|
||||
{row.taskNo}
|
||||
</Typography.Link>
|
||||
<span className="muted">{row.platformOrderId}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
minWidth: 210,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.skuName || '-'}</span>
|
||||
<span className="muted">{row.skuCode || row.executorKey}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
minWidth: 250,
|
||||
render: (_, row) => (
|
||||
<Space size={[0, 6]} wrap>
|
||||
<StatusTag value={row.status} kind="task" />
|
||||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||||
<StatusTag value={row.customerStatus} kind="customer" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
minWidth: 180,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.roleName || '-'}</span>
|
||||
<span className="muted">{row.roleId || row.loginType || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{formatAdminDateTime(row.updatedAt)}</span>
|
||||
<span className="muted">创建 {formatAdminDateTime(row.createdAt)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '异常 / 操作',
|
||||
minWidth: 260,
|
||||
fixed: 'right',
|
||||
render: (_, row) => {
|
||||
const rowLoading = actionLoadingId === row.taskId
|
||||
const actionRunning = actionLoadingId !== null
|
||||
const canRetry =
|
||||
canManageTaskLifecycle &&
|
||||
row.executorKey !== 'manual_dispatch' &&
|
||||
['retry_pending', 'manual_review'].includes(row.status)
|
||||
const canRebind = canRebindListTask(row, canManageTaskLifecycle)
|
||||
const canClose = canCloseTasks && !['redeemed', 'closed'].includes(row.status)
|
||||
const hasActions = canManageTaskLifecycle || canRebind || canClose
|
||||
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text type={row.lastError ? 'danger' : 'secondary'} ellipsis>
|
||||
{row.lastError || '当前无异常'}
|
||||
</Typography.Text>
|
||||
{hasActions ? (
|
||||
<Space size={[6, 6]} wrap>
|
||||
{canManageTaskLifecycle ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
ghost
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={actionRunning || !canRetry}
|
||||
loading={rowLoading}
|
||||
onClick={() => handleActionCommand('retry', row)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
{canRebind ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
disabled={actionRunning}
|
||||
loading={rowLoading}
|
||||
onClick={() => handleActionCommand('rebind_role', row)}
|
||||
>
|
||||
换绑
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UserSwitchOutlined />}
|
||||
disabled={actionRunning}
|
||||
loading={rowLoading}
|
||||
onClick={() => handleActionCommand('manual_review', row)}
|
||||
>
|
||||
转人工
|
||||
</Button>
|
||||
) : null}
|
||||
{canClose ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
disabled={actionRunning}
|
||||
loading={rowLoading}
|
||||
onClick={() => handleActionCommand('close', row)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function handleActionCommand(actionKey: TaskListAction, item: AdminTaskListItem) {
|
||||
const actionConfig = resolveTaskListAction(actionKey, item)
|
||||
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: actionConfig.confirmText,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingId(item.taskId)
|
||||
try {
|
||||
const response = await actionConfig.action()
|
||||
if (response.data.claimUrl) {
|
||||
setLastClaimUrl(response.data.claimUrl)
|
||||
}
|
||||
message.success(actionConfig.successMessage)
|
||||
await query.refetch()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resolveTaskListAction(actionKey: TaskListAction, item: AdminTaskListItem): {
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>
|
||||
confirmText: string
|
||||
successMessage: string
|
||||
} {
|
||||
if (actionKey === 'retry') {
|
||||
return {
|
||||
action: () => retryAdminTask(item.taskId),
|
||||
confirmText: `确认重试任务 ${item.taskNo} 吗?`,
|
||||
successMessage: '任务已重试',
|
||||
}
|
||||
}
|
||||
|
||||
if (actionKey === 'rebind_role') {
|
||||
return {
|
||||
action: () => rebindAdminTaskKuaishouCloudRole(item.taskId),
|
||||
confirmText: `确认为任务 ${item.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
successMessage: '角色换绑资源已准备完成',
|
||||
}
|
||||
}
|
||||
|
||||
if (actionKey === 'manual_review') {
|
||||
return {
|
||||
action: () => markAdminTaskManualReview(item.taskId),
|
||||
confirmText: `确认将任务 ${item.taskNo} 转为人工处理吗?`,
|
||||
successMessage: '任务已转人工处理',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: () => closeAdminTask(item.taskId),
|
||||
confirmText: `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
successMessage: '任务已关闭',
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(values: TaskFilterForm) {
|
||||
const { dateRange, ...restValues } = values
|
||||
const [dateFrom, dateTo] = Array.isArray(dateRange)
|
||||
? dateRange.map((item: { format?: (pattern: string) => string }) =>
|
||||
item?.format ? item.format('YYYY-MM-DD') : '',
|
||||
)
|
||||
: ['', '']
|
||||
|
||||
setSearchParams(
|
||||
cleanParams({
|
||||
...restValues,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: 1,
|
||||
pageSize,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="任务" description="跟踪履约任务状态、客户步骤和资源准备进度。" />
|
||||
|
||||
<Card>
|
||||
<Form<TaskFilterForm>
|
||||
layout="inline"
|
||||
initialValues={{
|
||||
...filters,
|
||||
dateRange:
|
||||
filters.dateFrom && filters.dateTo
|
||||
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
|
||||
: undefined,
|
||||
}}
|
||||
onFinish={applyFilters}
|
||||
className="filter-form"
|
||||
>
|
||||
<Form.Item name="status">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="任务状态"
|
||||
options={adminTaskStatusOptions}
|
||||
style={{ minWidth: 170 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="platformOrderId">
|
||||
<Input allowClear placeholder="平台订单号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="taskNo">
|
||||
<Input allowClear placeholder="任务号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="roleId">
|
||||
<Input allowClear placeholder="角色 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="skuCode">
|
||||
<Input allowClear placeholder="商品名 / SKU" />
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{lastClaimUrl ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="最近可用领取链接"
|
||||
description={
|
||||
<Typography.Text copyable={{ text: lastClaimUrl }}>{lastClaimUrl}</Typography.Text>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Table<AdminTaskListItem>
|
||||
rowKey="taskId"
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
scroll={{ x: 1240 }}
|
||||
pagination={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function canRebindListTask(item: AdminTaskListItem, canManageTaskLifecycle: boolean) {
|
||||
return (
|
||||
canManageTaskLifecycle &&
|
||||
item.executorKey === 'kuaishou_ct_assisted' &&
|
||||
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
|
||||
)
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (normalized) {
|
||||
next.set(key, normalized)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import {
|
||||
DownOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Result,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { MenuProps, TableColumnsType } from 'antd'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import {
|
||||
createAdminUser,
|
||||
fetchAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
updateAdminUserRole,
|
||||
updateAdminUserStatus,
|
||||
} from '@/services/admin'
|
||||
import type { AdminRole, AdminUserListItem } from '@/types/admin'
|
||||
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type UserFilterForm = {
|
||||
username?: string
|
||||
role?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type CreateUserForm = {
|
||||
username: string
|
||||
password: string
|
||||
role: AdminRole
|
||||
}
|
||||
|
||||
const USER_ROLE_OPTIONS = [
|
||||
{ label: '普通运营', value: 'operator' },
|
||||
{ label: '客服', value: 'support' },
|
||||
{ label: '管理员', value: 'admin' },
|
||||
]
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const isAdmin = hasAdminRole('admin')
|
||||
const currentUserId = getAdminUserId()
|
||||
const queryClient = useQueryClient()
|
||||
const { message, modal } = App.useApp()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [createForm] = Form.useForm<CreateUserForm>()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||
const filters = {
|
||||
username: searchParams.get('username') || '',
|
||||
role: searchParams.get('role') || '',
|
||||
status: searchParams.get('status') || '',
|
||||
}
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
[filters.role, filters.status, filters.username, page, pageSize],
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-users', queryParams],
|
||||
enabled: isAdmin,
|
||||
queryFn: () => fetchAdminUsers(queryParams),
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
async function reloadUsers() {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||
}
|
||||
|
||||
async function submitCreate(values: CreateUserForm) {
|
||||
setCreating(true)
|
||||
|
||||
try {
|
||||
await createAdminUser({
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
role: values.role,
|
||||
status: 'active',
|
||||
})
|
||||
message.success('后台用户已创建')
|
||||
createForm.resetFields()
|
||||
setSearchParams(cleanParams({ ...filters, page: 1, pageSize }))
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '创建后台用户失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmUpdateRole(item: AdminUserListItem, nextRole: AdminRole) {
|
||||
if (item.role === nextRole) return
|
||||
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: `确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await updateAdminUserRole(item.userId, { role: nextRole })
|
||||
message.success('用户角色已更新')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '更新用户角色失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function confirmToggleStatus(item: AdminUserListItem) {
|
||||
const nextStatus = item.status === 'active' ? 'disabled' : 'active'
|
||||
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: `确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await updateAdminUserStatus(item.userId, { status: nextStatus })
|
||||
message.success('用户状态已更新')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '更新用户状态失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openResetPasswordModal(item: AdminUserListItem) {
|
||||
const form = ModalFormStore.create<{ password: string }>()
|
||||
|
||||
modal.confirm({
|
||||
title: '重置密码',
|
||||
content: (
|
||||
<Form
|
||||
layout="vertical"
|
||||
preserve={false}
|
||||
initialValues={{ password: '' }}
|
||||
ref={form.bind}
|
||||
className="modal-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={`请输入 ${item.username} 的新密码`}
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="至少 8 位" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '提交',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
const values = await form.validate()
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await resetAdminUserPassword(item.userId, { password: values.password.trim() })
|
||||
message.success('用户密码已重置')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '重置密码失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function applyFilters(values: UserFilterForm) {
|
||||
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="后台用户" description="管理员可维护后台账号、角色和启停状态。" />
|
||||
<Result status="warning" title="仅管理员可以访问用户管理。" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminUserListItem> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'userId',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
dataIndex: 'username',
|
||||
minWidth: 150,
|
||||
render: (value, row) => (
|
||||
<Space>
|
||||
<Typography.Text strong>{value}</Typography.Text>
|
||||
{row.userId === currentUserId ? <Tag>当前账号</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 120,
|
||||
render: (value: AdminRole) => formatRoleLabel(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 140,
|
||||
render: (value) => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 310,
|
||||
render: (_, row) => {
|
||||
const isCurrent = row.userId === currentUserId
|
||||
const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({
|
||||
key: item.value,
|
||||
label: `设为${item.label}`,
|
||||
disabled: row.role === item.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<Dropdown
|
||||
disabled={isCurrent}
|
||||
menu={{
|
||||
items: roleMenuItems,
|
||||
onClick: ({ key }) => confirmUpdateRole(row, key as AdminRole),
|
||||
}}
|
||||
>
|
||||
<Button disabled={isCurrent} loading={actionLoadingId === row.userId}>
|
||||
改角色 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button
|
||||
disabled={isCurrent}
|
||||
loading={actionLoadingId === row.userId}
|
||||
onClick={() => confirmToggleStatus(row)}
|
||||
>
|
||||
{row.status === 'active' ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={actionLoadingId === row.userId}
|
||||
onClick={() => openResetPasswordModal(row)}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="后台用户"
|
||||
description="管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。"
|
||||
/>
|
||||
|
||||
<Card className="users-action-card">
|
||||
<div className="users-action-row">
|
||||
<section className="users-action-section">
|
||||
<div className="users-action-title">筛选用户</div>
|
||||
<Form<UserFilterForm>
|
||||
layout="inline"
|
||||
initialValues={filters}
|
||||
onFinish={applyFilters}
|
||||
className="filter-form users-action-form"
|
||||
>
|
||||
<Form.Item name="username">
|
||||
<Input allowClear placeholder="账号筛选" className="users-input-md" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="角色"
|
||||
options={adminUserRoleOptions}
|
||||
className="users-select-sm"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
options={adminUserStatusOptions}
|
||||
className="users-select-sm"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<section className="users-action-section">
|
||||
<div className="users-action-title">新增用户</div>
|
||||
<Form<CreateUserForm>
|
||||
form={createForm}
|
||||
layout="inline"
|
||||
initialValues={{ role: 'operator' }}
|
||||
onFinish={submitCreate}
|
||||
className="filter-form users-action-form"
|
||||
>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
|
||||
<Input placeholder="新账号" className="users-input-md" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
|
||||
<Select options={USER_ROLE_OPTIONS} className="users-select-sm" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<PlusOutlined />} loading={creating}>
|
||||
新增用户
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{query.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '读取后台用户失败'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
title="用户列表"
|
||||
extra={<Typography.Text type="secondary">共 {data?.pagination.total || 0} 个账号</Typography.Text>}
|
||||
>
|
||||
<Table<AdminUserListItem>
|
||||
rowKey="userId"
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
locale={{ emptyText: <Empty description="暂无用户" /> }}
|
||||
scroll={{ x: 1060 }}
|
||||
pagination={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRoleLabel(role: AdminRole) {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'support') return '客服'
|
||||
return '普通运营'
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (normalized) {
|
||||
next.set(key, normalized)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
class ModalFormStore<T extends object> {
|
||||
private form: ReturnType<typeof Form.useForm<T>>[0] | null = null
|
||||
|
||||
static create<T extends object>() {
|
||||
return new ModalFormStore<T>()
|
||||
}
|
||||
|
||||
bind = (instance: unknown) => {
|
||||
this.form = instance as ReturnType<typeof Form.useForm<T>>[0] | null
|
||||
}
|
||||
|
||||
async validate(): Promise<T> {
|
||||
if (!this.form) {
|
||||
return {} as T
|
||||
}
|
||||
|
||||
return this.form.validateFields()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminCloudtentaclesOverrideRules,
|
||||
fetchAdminCloudtentaclesSkuList,
|
||||
fetchAdminCloudtentaclesSourceConfig,
|
||||
fetchAdminKuaishouFeifeiConfig,
|
||||
matchAdminKuaishouFeifeiProduct,
|
||||
saveAdminCloudtentaclesOverrideRules,
|
||||
syncAdminKuaishouFeifeiProducts,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminCloudtentaclesOverrideDeliveryItem,
|
||||
AdminCloudtentaclesOverrideRule,
|
||||
AdminCloudtentaclesSessionItem,
|
||||
AdminCloudtentaclesSessionsMap,
|
||||
AdminCloudtentaclesSkuItem,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
AdminKuaishouFeifeiConfigResponse,
|
||||
AdminKuaishouFeifeiMatchResult,
|
||||
AdminKuaishouFeifeiProductRule,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
type EditableOverrideRule = Omit<AdminCloudtentaclesOverrideRule, 'normalizedProductName'> & {
|
||||
normalizedProductName?: string
|
||||
}
|
||||
|
||||
type SourceRow = AdminCloudtentaclesSourceItem & {
|
||||
session: AdminCloudtentaclesSessionItem | null
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
export default function AdminPlatformFulfillmentPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="履约配置"
|
||||
description="维护 91 卡券商品到 kuaishou-feifei 与 cloudtentacles 的履约映射规则。"
|
||||
/>
|
||||
|
||||
{!hasAdminRole('admin') ? (
|
||||
<Card>
|
||||
<Empty description="仅管理员可以查看履约配置" />
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs
|
||||
className="platform-tabs"
|
||||
destroyOnHidden={false}
|
||||
items={[
|
||||
{
|
||||
key: 'kuaishou-feifei',
|
||||
label: 'kuaishou-feifei',
|
||||
children: <KuaishouFeifeiFulfillmentPanel />,
|
||||
},
|
||||
{
|
||||
key: 'kuaishou-cloud',
|
||||
label: 'kuaishou-cloud',
|
||||
children: <KuaishouCloudFulfillmentPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function KuaishouFeifeiFulfillmentPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [matching, setMatching] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [filePath, setFilePath] = useState('')
|
||||
const [rules, setRules] = useState<AdminKuaishouFeifeiProductRule[]>([])
|
||||
const [matchInput, setMatchInput] = useState('')
|
||||
const [matchResult, setMatchResult] = useState<AdminKuaishouFeifeiMatchResult | null>(null)
|
||||
const [lastSyncText, setLastSyncText] = useState('')
|
||||
|
||||
const enabledRuleCount = rules.filter((rule) => rule.enabled !== false).length
|
||||
const mappingCount = rules.filter(
|
||||
(rule) => rule.productName.trim() && rule.productCode.trim(),
|
||||
).length
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig()
|
||||
}, [])
|
||||
|
||||
async function loadConfig() {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetchAdminKuaishouFeifeiConfig()
|
||||
hydrateConfig(response.data)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-feifei 履约映射失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncProducts() {
|
||||
setSyncing(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await syncAdminKuaishouFeifeiProducts({ status: 'on_sale' })
|
||||
hydrateConfig(response.data)
|
||||
const text = `商品 ${response.data.sync.productCount} 个,映射 ${response.data.sync.ruleCount} 条`
|
||||
setLastSyncText(text)
|
||||
showSuccess(`kuaishou-feifei 商品映射已同步,${text}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '同步 kuaishou-feifei 商品映射失败'
|
||||
setErrorMessage(message)
|
||||
showError(message)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function previewMatch() {
|
||||
const productName = matchInput.trim()
|
||||
if (!productName) {
|
||||
showError('请输入 91 商品名')
|
||||
return
|
||||
}
|
||||
|
||||
setMatching(true)
|
||||
setMatchResult(null)
|
||||
|
||||
try {
|
||||
const response = await matchAdminKuaishouFeifeiProduct(productName)
|
||||
setMatchResult(response.data)
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '预览 kuaishou-feifei 映射失败')
|
||||
} finally {
|
||||
setMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateConfig(data: AdminKuaishouFeifeiConfigResponse) {
|
||||
setFilePath(data.filePath || '')
|
||||
setRules(
|
||||
Array.isArray(data.source.productRules)
|
||||
? data.source.productRules.map((rule) => normalizeFeifeiRule(rule))
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminKuaishouFeifeiProductRule> = [
|
||||
{
|
||||
title: 'feifei 商品名',
|
||||
dataIndex: 'productName',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis>
|
||||
{row.productName || row.skuName || '-'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{row.normalizedProductName || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: 'product_code', dataIndex: 'productCode', minWidth: 150 },
|
||||
{ title: '展示名称', dataIndex: 'skuName', minWidth: 220 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
render: (_, row) => (
|
||||
<Tag color={row.enabled !== false ? 'green' : 'default'}>
|
||||
{row.enabled !== false ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '来源', dataIndex: 'notes', minWidth: 180 },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return <Spin />
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<div className="metric-grid four">
|
||||
<MetricCard label="名字映射" value={String(mappingCount)} detail={`启用 ${enabledRuleCount} 条`} />
|
||||
<MetricCard label="匹配方式" value="同名" detail="使用 91 商品名规范化匹配" />
|
||||
<MetricCard
|
||||
label="配置文件"
|
||||
value={filePath ? 'GUI' : '默认'}
|
||||
detail={filePath || 'data/kuaishou-feifei-config.json'}
|
||||
/>
|
||||
<Card>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadConfig}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} loading={syncing} onClick={syncProducts}>
|
||||
同步商品映射
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="商品名字映射"
|
||||
extra={<Typography.Text type="secondary">{lastSyncText || 'feifei 商品名会按规范化名字匹配 91 商品名。'}</Typography.Text>}
|
||||
>
|
||||
<Space.Compact className="full-width">
|
||||
<Input
|
||||
allowClear
|
||||
value={matchInput}
|
||||
placeholder="91 商品名,例如 套装-Alan Walker"
|
||||
onChange={(event) => setMatchInput(event.target.value)}
|
||||
onPressEnter={previewMatch}
|
||||
/>
|
||||
<Button icon={<SearchOutlined />} loading={matching} onClick={previewMatch}>
|
||||
预览命中
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{matchResult ? (
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type={matchResult.matched ? 'success' : 'warning'}
|
||||
showIcon
|
||||
message={matchResult.matched ? '已命中' : '未命中'}
|
||||
description={
|
||||
matchResult.match
|
||||
? `${matchResult.match.productName} -> ${matchResult.match.productCode}`
|
||||
: matchResult.normalizedProductName || matchResult.productName
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Table<AdminKuaishouFeifeiProductRule>
|
||||
rowKey={(row) => row.id || `${row.productName}-${row.productCode}`}
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
scroll={{ x: 920, y: 520 }}
|
||||
locale={{ emptyText: '暂无 kuaishou-feifei 商品映射' }}
|
||||
className="platform-section-gap"
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function KuaishouCloudFulfillmentPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [skuLoading, setSkuLoading] = useState(false)
|
||||
const [overrideSaving, setOverrideSaving] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [skuErrorMessage, setSkuErrorMessage] = useState('')
|
||||
const [overrideErrorMessage, setOverrideErrorMessage] = useState('')
|
||||
const [selectedSourceKey, setSelectedSourceKey] = useState('')
|
||||
const [sources, setSources] = useState<AdminCloudtentaclesSourceItem[]>([])
|
||||
const [sessions, setSessions] = useState<AdminCloudtentaclesSessionsMap>({})
|
||||
const [skuItems, setSkuItems] = useState<AdminCloudtentaclesSkuItem[]>([])
|
||||
const [overrideEnabled, setOverrideEnabled] = useState(true)
|
||||
const [overrideRules, setOverrideRules] = useState<EditableOverrideRule[]>([])
|
||||
const [matchInput, setMatchInput] = useState('')
|
||||
|
||||
const sourceRows = useMemo(
|
||||
() =>
|
||||
sources.map((source) => {
|
||||
const session = sessions[source.key] || null
|
||||
return {
|
||||
...source,
|
||||
session,
|
||||
ready: source.enabled !== false && Boolean(session?.hasToken),
|
||||
}
|
||||
}),
|
||||
[sessions, sources],
|
||||
)
|
||||
const readySources = sourceRows.filter((source) => source.ready)
|
||||
const selectedSource = sourceRows.find((source) => source.key === selectedSourceKey) || null
|
||||
const matchedSku = useMemo(() => {
|
||||
const normalizedInput = normalizeMatchName(matchInput)
|
||||
if (!normalizedInput) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
skuItems.find((item) => String(item.name || '').trim() === matchInput.trim()) ||
|
||||
skuItems.find((item) => normalizeMatchName(item.name) === normalizedInput) ||
|
||||
null
|
||||
)
|
||||
}, [matchInput, skuItems])
|
||||
const metrics = {
|
||||
sourceCount: sources.length,
|
||||
readySourceCount: readySources.length,
|
||||
skuCount: skuItems.length,
|
||||
inventoryTotal: skuItems.reduce((sum, item) => sum + Math.max(0, Number(item.inventory || 0)), 0),
|
||||
overrideRuleCount: overrideRules.length,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSourceKey) {
|
||||
void loadSkuList(selectedSourceKey)
|
||||
} else {
|
||||
setSkuItems([])
|
||||
}
|
||||
}, [selectedSourceKey])
|
||||
|
||||
async function loadPage() {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const [sourceResponse, overrideResponse] = await Promise.all([
|
||||
fetchAdminCloudtentaclesSourceConfig(),
|
||||
fetchAdminCloudtentaclesOverrideRules(),
|
||||
])
|
||||
const nextSources = Array.isArray(sourceResponse.data.sources) ? sourceResponse.data.sources : []
|
||||
const nextSessions = sourceResponse.data.sessions || {}
|
||||
const nextRows = nextSources.map((source) => {
|
||||
const session = nextSessions[source.key] || null
|
||||
return {
|
||||
...source,
|
||||
session,
|
||||
ready: source.enabled !== false && Boolean(session?.hasToken),
|
||||
}
|
||||
})
|
||||
|
||||
setSources(nextSources)
|
||||
setSessions(nextSessions)
|
||||
setOverrideEnabled(overrideResponse.data.enabled !== false)
|
||||
setOverrideRules(
|
||||
Array.isArray(overrideResponse.data.rules)
|
||||
? overrideResponse.data.rules.map((rule) => normalizeEditableRule(rule))
|
||||
: [],
|
||||
)
|
||||
setSelectedSourceKey(resolveDefaultSourceKey(nextRows, selectedSourceKey))
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 履约配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkuList(sourceKey = selectedSourceKey) {
|
||||
if (!sourceKey) {
|
||||
setSkuItems([])
|
||||
return
|
||||
}
|
||||
|
||||
setSkuLoading(true)
|
||||
setSkuErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesSkuList({ sourceKey })
|
||||
setSkuItems(Array.isArray(response.data.items) ? response.data.items : [])
|
||||
} catch (error) {
|
||||
setSkuItems([])
|
||||
setSkuErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 商品失败')
|
||||
} finally {
|
||||
setSkuLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOverrideRules() {
|
||||
setOverrideSaving(true)
|
||||
setOverrideErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await saveAdminCloudtentaclesOverrideRules({
|
||||
enabled: overrideEnabled,
|
||||
rules: overrideRules.map((rule) => ({
|
||||
id: rule.id,
|
||||
enabled: rule.enabled,
|
||||
productName: rule.productName,
|
||||
sourceKey: rule.sourceKey,
|
||||
deliveryItems: rule.deliveryItems,
|
||||
notes: rule.notes,
|
||||
})),
|
||||
})
|
||||
setOverrideEnabled(response.data.enabled !== false)
|
||||
setOverrideRules(
|
||||
Array.isArray(response.data.rules)
|
||||
? response.data.rules.map((rule) => normalizeEditableRule(rule))
|
||||
: [],
|
||||
)
|
||||
showSuccess(`商品覆盖规则已保存,共 ${response.data.rules.length} 条`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存 cloudtentacles 覆盖规则失败'
|
||||
setOverrideErrorMessage(message)
|
||||
showError(message)
|
||||
} finally {
|
||||
setOverrideSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function addOverrideRule() {
|
||||
setOverrideRules((current) => [
|
||||
{
|
||||
id: createRuleId(),
|
||||
enabled: true,
|
||||
productName: matchInput.trim(),
|
||||
normalizedProductName: normalizeMatchName(matchInput),
|
||||
sourceKey: selectedSourceKey,
|
||||
deliveryItems: [createDeliveryItem(skuItems)],
|
||||
notes: '',
|
||||
},
|
||||
...current,
|
||||
])
|
||||
}
|
||||
|
||||
function updateRule(index: number, patch: Partial<EditableOverrideRule>) {
|
||||
setOverrideRules((current) =>
|
||||
current.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, ...patch } : rule)),
|
||||
)
|
||||
}
|
||||
|
||||
function updateDeliveryItem(ruleIndex: number, itemIndex: number, patch: Partial<AdminCloudtentaclesOverrideDeliveryItem>) {
|
||||
setOverrideRules((current) =>
|
||||
current.map((rule, currentRuleIndex) => {
|
||||
if (currentRuleIndex !== ruleIndex) {
|
||||
return rule
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
deliveryItems: rule.deliveryItems.map((item, currentItemIndex) =>
|
||||
currentItemIndex === itemIndex ? { ...item, ...patch } : item,
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeOverrideRule(index: number) {
|
||||
setOverrideRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index))
|
||||
}
|
||||
|
||||
function addDeliveryItem(ruleIndex: number) {
|
||||
setOverrideRules((current) =>
|
||||
current.map((rule, currentRuleIndex) =>
|
||||
currentRuleIndex === ruleIndex
|
||||
? { ...rule, deliveryItems: [...rule.deliveryItems, createDeliveryItem(skuItems)] }
|
||||
: rule,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function removeDeliveryItem(ruleIndex: number, itemIndex: number) {
|
||||
setOverrideRules((current) =>
|
||||
current.map((rule, currentRuleIndex) => {
|
||||
if (currentRuleIndex !== ruleIndex) {
|
||||
return rule
|
||||
}
|
||||
|
||||
const nextItems = rule.deliveryItems.filter((_, currentItemIndex) => currentItemIndex !== itemIndex)
|
||||
return {
|
||||
...rule,
|
||||
deliveryItems: nextItems.length ? nextItems : [createDeliveryItem(skuItems)],
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function handleDeliverySkuChange(ruleIndex: number, itemIndex: number, skuId: number) {
|
||||
const sku = skuItems.find((item) => Number(item.id || 0) === Number(skuId || 0))
|
||||
updateDeliveryItem(ruleIndex, itemIndex, {
|
||||
cloudSkuId: Number(skuId || 0) || 0,
|
||||
cloudSkuName: String(sku?.name || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Spin />
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<div className="metric-grid five">
|
||||
<MetricCard label="履约账号" value={`${metrics.readySourceCount} / ${metrics.sourceCount}`} />
|
||||
<MetricCard label="可发商品" value={String(metrics.skuCount)} />
|
||||
<MetricCard label="总库存" value={String(metrics.inventoryTotal)} />
|
||||
<MetricCard label="覆盖规则" value={String(metrics.overrideRuleCount)} />
|
||||
<Card>
|
||||
<Button icon={<ReloadOutlined />} loading={loading || skuLoading} onClick={loadPage}>
|
||||
刷新
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="platform-split-layout">
|
||||
<Card title="cloudtentacles 账号" className="platform-side-card">
|
||||
{sourceRows.length === 0 ? (
|
||||
<Empty description="暂无账号,请先到平台配置登录。" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width">
|
||||
{sourceRows.map((source) => (
|
||||
<button
|
||||
key={source.key}
|
||||
type="button"
|
||||
className={
|
||||
source.key === selectedSourceKey
|
||||
? 'platform-select-card active'
|
||||
: 'platform-select-card'
|
||||
}
|
||||
onClick={() => setSelectedSourceKey(source.key)}
|
||||
>
|
||||
<span>
|
||||
<strong>{formatSourceLabel(source)}</strong>
|
||||
<small>{source.username || source.key}</small>
|
||||
</span>
|
||||
<Tag color={source.ready ? 'green' : source.enabled === false ? 'default' : 'orange'}>
|
||||
{source.ready ? '可用' : source.enabled === false ? '已停用' : '未登录'}
|
||||
</Tag>
|
||||
</button>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<section className="platform-panel-stack">
|
||||
<Card
|
||||
title="匹配状态与覆盖规则"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary">
|
||||
当前账号:{selectedSource ? formatSourceLabel(selectedSource) : '-'}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={skuLoading}
|
||||
disabled={!selectedSourceKey}
|
||||
onClick={() => loadSkuList()}
|
||||
>
|
||||
刷新商品
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{skuErrorMessage ? (
|
||||
<Alert type="error" showIcon message={skuErrorMessage} className="platform-section-gap" />
|
||||
) : null}
|
||||
|
||||
<Space className="full-width" direction="vertical">
|
||||
<Space.Compact className="full-width">
|
||||
<Input
|
||||
allowClear
|
||||
value={matchInput}
|
||||
placeholder="输入 91 卡券 productNo / 商品名字,查看命中的 cloudtentacles 商品"
|
||||
onChange={(event) => setMatchInput(event.target.value)}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addOverrideRule}>
|
||||
生成规则
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{matchedSku ? (
|
||||
<Tag color="green">命中 #{matchedSku.id} {matchedSku.name}</Tag>
|
||||
) : matchInput.trim() ? (
|
||||
<Tag color="orange">未命中</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="商品覆盖规则"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Switch
|
||||
checked={overrideEnabled}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
onChange={setOverrideEnabled}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addOverrideRule}>
|
||||
新增规则
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={overrideSaving}
|
||||
onClick={saveOverrideRules}
|
||||
>
|
||||
保存规则
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{overrideErrorMessage ? (
|
||||
<Alert type="error" showIcon message={overrideErrorMessage} className="platform-section-gap" />
|
||||
) : null}
|
||||
|
||||
{overrideRules.length === 0 ? (
|
||||
<Empty description="暂无覆盖规则" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width" size={12}>
|
||||
{overrideRules.map((rule, ruleIndex) => (
|
||||
<Card
|
||||
key={rule.id}
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={rule.enabled !== false}
|
||||
onChange={(enabled) => updateRule(ruleIndex, { enabled })}
|
||||
/>
|
||||
<span>{rule.productName || '未命名规则'}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeOverrideRule(ruleIndex)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="platform-rule-head">
|
||||
<Input
|
||||
value={rule.productName}
|
||||
placeholder="91 商品名 / productNo"
|
||||
onChange={(event) => updateRule(ruleIndex, { productName: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
value={rule.sourceKey || undefined}
|
||||
placeholder="全部账号"
|
||||
options={[
|
||||
{ label: '全部账号', value: '' },
|
||||
...sourceRows.map((source) => ({
|
||||
label: formatSourceLabel(source),
|
||||
value: source.key,
|
||||
})),
|
||||
]}
|
||||
onChange={(sourceKey) => {
|
||||
updateRule(ruleIndex, { sourceKey: String(sourceKey || '') })
|
||||
if (sourceKey) {
|
||||
setSelectedSourceKey(String(sourceKey))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" className="full-width platform-section-gap" size={8}>
|
||||
{rule.deliveryItems.map((item, itemIndex) => (
|
||||
<div key={`${rule.id}-${itemIndex}`} className="platform-delivery-row">
|
||||
<Select
|
||||
showSearch
|
||||
value={item.cloudSkuId || undefined}
|
||||
placeholder="选择 cloudtentacles 商品"
|
||||
optionFilterProp="label"
|
||||
options={skuItems.map((sku) => ({
|
||||
label: formatSkuOption(sku),
|
||||
value: sku.id,
|
||||
}))}
|
||||
onChange={(skuId) => handleDeliverySkuChange(ruleIndex, itemIndex, Number(skuId || 0))}
|
||||
/>
|
||||
<Input
|
||||
value={item.cloudSkuName}
|
||||
placeholder="cloudtentacles 商品名"
|
||||
onChange={(event) =>
|
||||
updateDeliveryItem(ruleIndex, itemIndex, {
|
||||
cloudSkuName: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={999}
|
||||
value={item.quantity}
|
||||
onChange={(quantity) =>
|
||||
updateDeliveryItem(ruleIndex, itemIndex, {
|
||||
quantity: Math.max(1, Number(quantity || 1) || 1),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button onClick={() => removeDeliveryItem(ruleIndex, itemIndex)}>删除</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={() => addDeliveryItem(ruleIndex)}>增加发货商品</Button>
|
||||
</Space>
|
||||
|
||||
<Input
|
||||
className="platform-section-gap"
|
||||
value={rule.notes}
|
||||
placeholder="备注"
|
||||
onChange={(event) => updateRule(ruleIndex, { notes: event.target.value })}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="cloudtentacles 商品列表">
|
||||
<Table<AdminCloudtentaclesSkuItem>
|
||||
rowKey="id"
|
||||
loading={skuLoading}
|
||||
dataSource={skuItems}
|
||||
columns={[
|
||||
{
|
||||
title: 'cloudtentacles 商品',
|
||||
dataIndex: 'name',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis>{row.name || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
#{row.id} · {row.description || row.name || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '91 自动匹配名',
|
||||
dataIndex: 'name',
|
||||
minWidth: 220,
|
||||
render: (value) => <code>{String(value || '-')}</code>,
|
||||
},
|
||||
{ title: '价格', dataIndex: 'price', width: 120, render: formatCloudPrice },
|
||||
{ title: '库存', dataIndex: 'inventory', width: 120, sorter: (a, b) => a.inventory - b.inventory },
|
||||
{
|
||||
title: '发货限制',
|
||||
width: 150,
|
||||
render: (_, row) => `${row.buyLimitMin || 1} - ${row.buyLimitMax || 1}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Tag color={Number(row.inventory || 0) > 0 ? 'green' : 'red'}>
|
||||
{Number(row.inventory || 0) > 0 ? '可履约' : '无库存'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
scroll={{ x: 1000, y: 420 }}
|
||||
locale={{ emptyText: '当前账号暂无可展示商品' }}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<Card className="metric-card">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
{detail ? <small>{detail}</small> : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeFeifeiRule(
|
||||
rule: Partial<AdminKuaishouFeifeiProductRule>,
|
||||
): AdminKuaishouFeifeiProductRule {
|
||||
const productName = String(rule.productName || '').trim()
|
||||
const productCode = String(rule.productCode || '').trim()
|
||||
|
||||
return {
|
||||
id: String(rule.id || productName || productCode).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
productName,
|
||||
normalizedProductName: String(rule.normalizedProductName || '').trim(),
|
||||
productCode,
|
||||
skuName: String(rule.skuName || productName || productCode).trim(),
|
||||
notes: String(rule.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEditableRule(rule: AdminCloudtentaclesOverrideRule): EditableOverrideRule {
|
||||
return {
|
||||
id: String(rule.id || createRuleId()).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
productName: String(rule.productName || '').trim(),
|
||||
normalizedProductName: String(rule.normalizedProductName || '').trim(),
|
||||
sourceKey: String(rule.sourceKey || '').trim(),
|
||||
deliveryItems:
|
||||
Array.isArray(rule.deliveryItems) && rule.deliveryItems.length > 0
|
||||
? rule.deliveryItems.map((item) => ({
|
||||
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
|
||||
cloudSkuName: String(item.cloudSkuName || '').trim(),
|
||||
quantity: Math.max(1, Number(item.quantity || 1) || 1),
|
||||
}))
|
||||
: [createDeliveryItem([])],
|
||||
notes: String(rule.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function createDeliveryItem(skuItems: AdminCloudtentaclesSkuItem[]): AdminCloudtentaclesOverrideDeliveryItem {
|
||||
const firstSku = skuItems[0]
|
||||
return {
|
||||
cloudSkuId: Number(firstSku?.id || 0) || 0,
|
||||
cloudSkuName: String(firstSku?.name || '').trim(),
|
||||
quantity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultSourceKey(sourceRows: SourceRow[], selectedSourceKey: string) {
|
||||
const current = sourceRows.find((source) => source.key === selectedSourceKey)
|
||||
if (current?.ready) {
|
||||
return current.key
|
||||
}
|
||||
|
||||
return sourceRows.find((source) => source.ready)?.key || sourceRows[0]?.key || ''
|
||||
}
|
||||
|
||||
function normalizeMatchName(value: unknown) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[【】\[\]()()]/g, ' ')
|
||||
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function formatSourceLabel(source: Pick<AdminCloudtentaclesSourceItem, 'key' | 'label' | 'username'>) {
|
||||
return source.label || source.username || source.key
|
||||
}
|
||||
|
||||
function formatCloudPrice(value: unknown) {
|
||||
const price = Number(value)
|
||||
return Number.isFinite(price) ? String(price) : '-'
|
||||
}
|
||||
|
||||
function formatSkuOption(sku: AdminCloudtentaclesSkuItem) {
|
||||
return `#${sku.id} ${sku.name}`
|
||||
}
|
||||
|
||||
function createRuleId() {
|
||||
return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user