feat: 完善 Rock 发货异常闭环与绑定判定
This commit is contained in:
@@ -605,6 +605,8 @@ export interface RockStats {
|
||||
using_danger_stock?: number;
|
||||
bound_overdue_stock?: number;
|
||||
anomaly_stock?: number;
|
||||
no_entitlement_stock?: number;
|
||||
unresolved_anomaly_stock?: number;
|
||||
auto_refresh_mismatch_enabled?: boolean;
|
||||
upstream_points: number;
|
||||
auto_delivery_enabled: boolean;
|
||||
@@ -687,6 +689,11 @@ export interface RockTokenItem {
|
||||
anomaly_reason?: string;
|
||||
anomaly_at?: string;
|
||||
anomaly_count?: number;
|
||||
has_open_incident?: boolean;
|
||||
incident_category?: string;
|
||||
incident_message?: string;
|
||||
incident_at?: string;
|
||||
incident_count?: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
@@ -865,6 +872,28 @@ export interface RockStaleRefreshResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RockDeliveryIncident {
|
||||
id: number;
|
||||
token_id: number;
|
||||
order_no: string;
|
||||
upstream_token_id: string;
|
||||
category: string;
|
||||
status: "open" | "resolved" | "ignored";
|
||||
blocking_retry: boolean;
|
||||
message: string;
|
||||
upstream_http_status: number;
|
||||
upstream_response: string;
|
||||
occurrences: number;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
resolved_at?: string;
|
||||
resolved_by?: number;
|
||||
resolution_action: string;
|
||||
resolution_note: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const rockApi = {
|
||||
getConfig: () =>
|
||||
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
|
||||
@@ -1028,6 +1057,38 @@ export const rockApi = {
|
||||
request
|
||||
.get(`/platform/rock/tokens/${id}/delivery-logs`)
|
||||
.then((r) => r.data.data as { list: RockDeliveryLog[] }),
|
||||
listTokenIncidents: (id: number) =>
|
||||
request
|
||||
.get(`/platform/rock/tokens/${id}/incidents`)
|
||||
.then((r) => r.data.data as { list: RockDeliveryIncident[] }),
|
||||
listIncidents: (params?: { page?: number; page_size?: number; status?: string; category?: string }) =>
|
||||
request
|
||||
.get("/platform/rock/incidents", { params })
|
||||
.then(
|
||||
(r) =>
|
||||
r.data.data as {
|
||||
list: RockDeliveryIncident[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
},
|
||||
),
|
||||
resolveIncident: (id: number, note?: string) =>
|
||||
request
|
||||
.patch(`/platform/rock/incidents/${id}`, { note })
|
||||
.then((r) => r.data.data as { message: string }),
|
||||
retryIncident: (id: number, note: string) =>
|
||||
request
|
||||
.post(`/platform/rock/incidents/${id}/retry`, { note })
|
||||
.then(
|
||||
(r) =>
|
||||
r.data.data as {
|
||||
order_no: string
|
||||
status: string
|
||||
pending?: boolean
|
||||
message?: string
|
||||
},
|
||||
),
|
||||
refreshTokenBinding: (id: number) =>
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/refresh-binding`)
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function PlatformRock() {
|
||||
}
|
||||
}
|
||||
|
||||
// 一键刷新异常 Token:绑定错误 + 使用超时 + 绑定超时,同步执行,单次最多 20 条
|
||||
// 一键刷新异常 Token:绑定错误 + 待绑定超时 + 绑定后未发货,同步执行,单次最多 20 条
|
||||
const handleRefreshStale = async () => {
|
||||
setRefreshingStale(true)
|
||||
try {
|
||||
@@ -218,7 +218,7 @@ export default function PlatformRock() {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动刷新开关:与「一键刷新」同规则(绑定错误 + 使用超时 + 绑定超时),切换即保存
|
||||
// 自动刷新开关:与「一键刷新」同规则(绑定错误 + 待绑定超时 + 绑定后未发货),切换即保存
|
||||
const handleToggleAutoRefreshMismatch = async (checked: boolean) => {
|
||||
const previous = autoRefreshMismatchEnabled
|
||||
setAutoRefreshMismatchEnabled(checked)
|
||||
|
||||
@@ -350,8 +350,8 @@ export function RockStrategyCard({
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="using_warn_days"
|
||||
label="使用中橙色预警"
|
||||
tooltip="扫码使用中的 Token 超过该天数后,状态标签变为橙色"
|
||||
label="待绑定橙色预警"
|
||||
tooltip="链接已发出但尚未扫码绑定的 Token 超过该天数后,状态标签变为橙色"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||
</Form.Item>
|
||||
@@ -359,7 +359,7 @@ export function RockStrategyCard({
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="using_danger_days"
|
||||
label="使用中红色预警"
|
||||
label="待绑定红色预警"
|
||||
tooltip="必须大于橙色预警天数;超过后状态标签变为红色"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||
@@ -368,8 +368,8 @@ export function RockStrategyCard({
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="bound_warn_days"
|
||||
label="绑定过期预警"
|
||||
tooltip="已绑定 Token 超过该天数后标记为绑定超时,并计入「绑定超时」筛选"
|
||||
label="绑定后未发货预警"
|
||||
tooltip="本轮真实绑定后超过该天数仍未完成发货,标记为「绑定后未发货」"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function RockTokenFilterBar({
|
||||
>
|
||||
<Radio.Button value="all">全部</Radio.Button>
|
||||
<Radio.Button value="available">可用</Radio.Button>
|
||||
<Radio.Button value="using">使用中</Radio.Button>
|
||||
<Radio.Button value="using">待绑定</Radio.Button>
|
||||
<Radio.Button value="bound">已绑定</Radio.Button>
|
||||
<Radio.Button value="redeemed">已发货</Radio.Button>
|
||||
<Radio.Button value="mismatch">
|
||||
@@ -87,7 +87,7 @@ export function RockTokenFilterBar({
|
||||
size="small"
|
||||
style={{ backgroundColor: '#f59e0b' }}
|
||||
>
|
||||
<span>使用超时</span>
|
||||
<span>待绑定超时</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
<Radio.Button value="bound_overdue">
|
||||
@@ -97,13 +97,23 @@ export function RockTokenFilterBar({
|
||||
size="small"
|
||||
style={{ backgroundColor: '#ef4444' }}
|
||||
>
|
||||
<span>绑定超时</span>
|
||||
<span>绑定后未发货</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
{/* 异常放在最后:派生筛选,只看历史流转,不改变主状态 */}
|
||||
<Radio.Button value="no_entitlement">
|
||||
<Badge
|
||||
count={stats?.no_entitlement_stock ?? 0}
|
||||
offset={[6, -2]}
|
||||
size="small"
|
||||
style={{ backgroundColor: '#dc2626' }}
|
||||
>
|
||||
<span>无权益</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
{/* 其余未处理错误统一进入异常 */}
|
||||
<Radio.Button value="anomaly">
|
||||
<Badge
|
||||
count={stats?.anomaly_stock ?? 0}
|
||||
count={stats?.unresolved_anomaly_stock ?? stats?.anomaly_stock ?? 0}
|
||||
offset={[6, -2]}
|
||||
size="small"
|
||||
style={{ backgroundColor: '#b91c1c' }}
|
||||
@@ -127,7 +137,7 @@ export function RockTokenFilterBar({
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7 }}>
|
||||
同时刷新三类异常 Token:绑定错误、使用超时、绑定超时。
|
||||
同时刷新三类 Token:绑定错误、待绑定超时、绑定后未发货。
|
||||
<br />
|
||||
单次最多 20 条,逐条间隔 1.5 秒,同一 Token 10 分钟内不重复刷新。
|
||||
<br />
|
||||
|
||||
@@ -70,6 +70,7 @@ export function RockTokenTable({
|
||||
const [openingUpstreamId, setOpeningUpstreamId] = useState<number | null>(null)
|
||||
const [releaseTarget, setReleaseTarget] = useState<RockTokenItem | null>(null)
|
||||
const [statusLogTarget, setStatusLogTarget] = useState<RockTokenItem | null>(null)
|
||||
const [statusLogTab, setStatusLogTab] = useState<'status' | 'incidents'>('status')
|
||||
|
||||
const handleOpenUpstream = async (id: number) => {
|
||||
// 先同步打开空白页,避免异步请求后 window.open 被浏览器拦截。
|
||||
@@ -227,7 +228,10 @@ export function RockTokenTable({
|
||||
onViewRawData,
|
||||
onResetToken: handleResetToken,
|
||||
onReleaseToken: setReleaseTarget,
|
||||
onViewStatusLogs: setStatusLogTarget,
|
||||
onViewStatusLogs: (record, tab = 'status') => {
|
||||
setStatusLogTab(tab)
|
||||
setStatusLogTarget(record)
|
||||
},
|
||||
onUpdateRemark: handleUpdateRemark,
|
||||
onMarkAbnormal: handleMarkAbnormal,
|
||||
})
|
||||
@@ -306,7 +310,16 @@ export function RockTokenTable({
|
||||
<TokenStatusLogDrawer
|
||||
open={Boolean(statusLogTarget)}
|
||||
token={statusLogTarget}
|
||||
initialTab={statusLogTab}
|
||||
onClose={() => setStatusLogTarget(null)}
|
||||
onRequestRelease={(token) => {
|
||||
setStatusLogTarget(null)
|
||||
setReleaseTarget(token)
|
||||
}}
|
||||
onIncidentChanged={() => {
|
||||
onReloadData()
|
||||
onReloadTokens()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -26,13 +26,14 @@ export interface TokenDeleteModalProps {
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '使用中',
|
||||
bound: '已绑定',
|
||||
using: '待扫码绑定',
|
||||
bound: '已绑定待发货',
|
||||
redeemed: '已发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
using_overdue: '使用超时',
|
||||
bound_overdue: '绑定超时',
|
||||
using_overdue: '待绑定超时',
|
||||
bound_overdue: '绑定后未发货',
|
||||
no_entitlement: '无权益',
|
||||
}
|
||||
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||
|
||||
@@ -29,25 +29,27 @@ export interface TokenRecycleBinDrawerProps {
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '使用中',
|
||||
bound: '已绑定',
|
||||
using: '待扫码绑定',
|
||||
bound: '已绑定待发货',
|
||||
redeemed: '已发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
using_overdue: '使用超时',
|
||||
bound_overdue: '绑定超时',
|
||||
using_overdue: '待绑定超时',
|
||||
bound_overdue: '绑定后未发货',
|
||||
no_entitlement: '无权益',
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'available', label: '可用库存' },
|
||||
{ value: 'using', label: '使用中' },
|
||||
{ value: 'bound', label: '已绑定' },
|
||||
{ value: 'using', label: '待扫码绑定' },
|
||||
{ value: 'bound', label: '已绑定待发货' },
|
||||
{ value: 'redeemed', label: '已发货' },
|
||||
{ value: 'mismatch', label: '绑定错误' },
|
||||
{ value: 'abnormal', label: '已上报失效' },
|
||||
{ value: 'using_overdue', label: '使用超时' },
|
||||
{ value: 'bound_overdue', label: '绑定超时' },
|
||||
{ value: 'using_overdue', label: '待绑定超时' },
|
||||
{ value: 'bound_overdue', label: '绑定后未发货' },
|
||||
{ value: 'no_entitlement', label: '无权益' },
|
||||
]
|
||||
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
Drawer,
|
||||
Empty,
|
||||
Grid,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Tabs,
|
||||
@@ -17,6 +20,7 @@ import { ReloadOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
rockApi,
|
||||
type RockDeliveryLog,
|
||||
type RockDeliveryIncident,
|
||||
type RockTokenItem,
|
||||
type RockTokenStatusLog,
|
||||
} from '../../api'
|
||||
@@ -28,8 +32,11 @@ const { Text, Paragraph } = Typography
|
||||
export interface TokenStatusLogDrawerProps {
|
||||
open: boolean
|
||||
token: RockTokenItem | null
|
||||
initialTab?: 'status' | 'delivery' | 'incidents'
|
||||
onClose: () => void
|
||||
loadLogs?: (tokenId: number) => Promise<RockTokenStatusLog[]>
|
||||
onIncidentChanged?: () => void
|
||||
onRequestRelease?: (token: RockTokenItem) => void
|
||||
}
|
||||
|
||||
const EVENT_META: Record<string, { label: string; color: string }> = {
|
||||
@@ -37,6 +44,7 @@ const EVENT_META: Record<string, { label: string; color: string }> = {
|
||||
status_changed: { label: '状态变更', color: 'blue' },
|
||||
deleted: { label: '删除', color: 'red' },
|
||||
restored: { label: '恢复', color: 'orange' },
|
||||
incident: { label: '发货异常', color: 'red' },
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
@@ -52,8 +60,26 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
package_sync: '上游包同步',
|
||||
delete: '删除',
|
||||
restore: '回收站恢复',
|
||||
incident: '发货异常',
|
||||
}
|
||||
|
||||
const INCIDENT_CATEGORY_LABELS: Record<string, string> = {
|
||||
no_entitlement: '无权益',
|
||||
duplicate_item: '重复道具',
|
||||
gift_exhausted: '礼包耗尽',
|
||||
token_already_redeemed: 'Token 已兑换',
|
||||
token_not_redeemable: 'Token 不可兑换',
|
||||
token_not_found: 'Token 不存在',
|
||||
upstream_timeout: '上游超时',
|
||||
upstream_rate_limited: '上游频控',
|
||||
ship_timeout: '发货超时',
|
||||
bind_mismatch: 'UID 不一致',
|
||||
order_cancelled: '订单取消冲突',
|
||||
other: '其他异常',
|
||||
}
|
||||
|
||||
const RETRYABLE_INCIDENTS = new Set(['upstream_timeout', 'upstream_rate_limited'])
|
||||
|
||||
// 发货链路的步骤中文名,用于报文时间线。
|
||||
const DELIVERY_STEP_LABELS: Record<string, string> = {
|
||||
submit_request: '提交发货',
|
||||
@@ -252,18 +278,30 @@ const preStyle: CSSProperties = {
|
||||
export function TokenStatusLogDrawer({
|
||||
open,
|
||||
token,
|
||||
initialTab = 'status',
|
||||
onClose,
|
||||
loadLogs,
|
||||
onIncidentChanged,
|
||||
onRequestRelease,
|
||||
}: TokenStatusLogDrawerProps) {
|
||||
const screens = Grid.useBreakpoint()
|
||||
const isMobile = !screens.md
|
||||
const [logs, setLogs] = useState<RockTokenStatusLog[]>([])
|
||||
const [deliveryLogs, setDeliveryLogs] = useState<RockDeliveryLog[]>([])
|
||||
const [incidents, setIncidents] = useState<RockDeliveryIncident[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingDelivery, setLoadingDelivery] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [deliveryError, setDeliveryError] = useState('')
|
||||
const [incidentError, setIncidentError] = useState('')
|
||||
const [loadingIncidents, setLoadingIncidents] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('status')
|
||||
const [incidentAction, setIncidentAction] = useState<{
|
||||
type: 'resolve' | 'retry' | 'abnormal'
|
||||
incident: RockDeliveryIncident
|
||||
} | null>(null)
|
||||
const [incidentNote, setIncidentNote] = useState('')
|
||||
const [submittingIncident, setSubmittingIncident] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return
|
||||
@@ -295,14 +333,69 @@ export function TokenStatusLogDrawer({
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const loadIncidents = useCallback(async () => {
|
||||
if (!token) return
|
||||
setLoadingIncidents(true)
|
||||
setIncidentError('')
|
||||
try {
|
||||
const res = await rockApi.listTokenIncidents(token.id)
|
||||
setIncidents(res.list || [])
|
||||
} catch (e) {
|
||||
setIncidentError(e instanceof Error ? e.message : '加载异常记录失败')
|
||||
} finally {
|
||||
setLoadingIncidents(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !token) return
|
||||
setLogs([])
|
||||
setDeliveryLogs([])
|
||||
setActiveTab('status')
|
||||
setIncidents([])
|
||||
setActiveTab(initialTab)
|
||||
void load()
|
||||
void loadDelivery()
|
||||
}, [open, token, load, loadDelivery])
|
||||
void loadIncidents()
|
||||
}, [open, token, initialTab, load, loadDelivery, loadIncidents])
|
||||
|
||||
const openIncidentAction = (
|
||||
type: 'resolve' | 'retry' | 'abnormal',
|
||||
incident: RockDeliveryIncident,
|
||||
) => {
|
||||
setIncidentNote('')
|
||||
setIncidentAction({ type, incident })
|
||||
}
|
||||
|
||||
const closeIncidentAction = () => {
|
||||
if (submittingIncident) return
|
||||
setIncidentAction(null)
|
||||
setIncidentNote('')
|
||||
}
|
||||
|
||||
const handleIncidentAction = async () => {
|
||||
if (!token || !incidentAction || !incidentNote.trim()) return
|
||||
setSubmittingIncident(true)
|
||||
try {
|
||||
if (incidentAction.type === 'resolve') {
|
||||
await rockApi.resolveIncident(incidentAction.incident.id, incidentNote.trim())
|
||||
message.success('异常记录已标记处理')
|
||||
} else if (incidentAction.type === 'retry') {
|
||||
const result = await rockApi.retryIncident(incidentAction.incident.id, incidentNote.trim())
|
||||
message.success(result.message || (result.pending ? '上游已受理,等待发货确认' : '人工重试成功'))
|
||||
} else {
|
||||
await rockApi.markAbnormal(token.id, incidentNote.trim())
|
||||
message.success('Token 已标记为已上报失效')
|
||||
}
|
||||
setIncidentAction(null)
|
||||
setIncidentNote('')
|
||||
await loadIncidents()
|
||||
onIncidentChanged?.()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '处理异常失败')
|
||||
} finally {
|
||||
setSubmittingIncident(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusContent = (
|
||||
<>
|
||||
@@ -397,6 +490,83 @@ export function TokenStatusLogDrawer({
|
||||
</>
|
||||
)
|
||||
|
||||
const incidentContent = (
|
||||
<>
|
||||
{incidentError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={incidentError}
|
||||
style={{ marginBottom: 12 }}
|
||||
action={<Button size="small" onClick={loadIncidents}>重试</Button>}
|
||||
/>
|
||||
)}
|
||||
{loadingIncidents ? (
|
||||
<div style={{ padding: '48px 0', textAlign: 'center' }}><Spin /></div>
|
||||
) : incidents.length === 0 ? (
|
||||
<Empty description="暂无发货异常记录" />
|
||||
) : (
|
||||
<Timeline
|
||||
items={incidents.map((incident) => ({
|
||||
key: incident.id,
|
||||
color: incident.status === 'open' ? 'red' : 'green',
|
||||
children: (
|
||||
<Space direction="vertical" size={5} style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Tag color={incident.category === 'no_entitlement' ? 'red' : 'orange'}>
|
||||
{INCIDENT_CATEGORY_LABELS[incident.category] || incident.category}
|
||||
</Tag>
|
||||
<Tag color={incident.status === 'open' ? 'error' : 'success'}>
|
||||
{incident.status === 'open' ? '待处理' : '已处理'}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
发生 {incident.occurrences} 次
|
||||
</Text>
|
||||
</Space>
|
||||
<Text style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{incident.message || '发货异常'}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{formatDateTime(incident.first_seen_at)} · 最近 {formatDateTime(incident.last_seen_at)}
|
||||
</Text>
|
||||
{incident.status === 'open' && (
|
||||
<Space size={6} wrap>
|
||||
{RETRYABLE_INCIDENTS.has(incident.category) && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => openIncidentAction('retry', incident)}
|
||||
>
|
||||
人工重试
|
||||
</Button>
|
||||
)}
|
||||
{token && (token.status === 'using' || token.status === 'bound') && onRequestRelease && (
|
||||
<Button size="small" onClick={() => onRequestRelease(token)}>
|
||||
释放回库存
|
||||
</Button>
|
||||
)}
|
||||
{token && token.status !== 'redeemed' && token.status !== 'abnormal' && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => openIncidentAction('abnormal', incident)}
|
||||
>
|
||||
上报失效
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" onClick={() => openIncidentAction('resolve', incident)}>
|
||||
标记已处理
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
@@ -407,10 +577,11 @@ export function TokenStatusLogDrawer({
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={loading || loadingDelivery}
|
||||
loading={loading || loadingDelivery || loadingIncidents}
|
||||
onClick={() => {
|
||||
void load()
|
||||
void loadDelivery()
|
||||
void loadIncidents()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
@@ -460,8 +631,64 @@ export function TokenStatusLogDrawer({
|
||||
label: `发货报文${deliveryLogs.length ? ` (${deliveryLogs.length})` : ''}`,
|
||||
children: deliveryContent,
|
||||
},
|
||||
{
|
||||
key: 'incidents',
|
||||
label: `异常记录${incidents.length ? ` (${incidents.length})` : ''}`,
|
||||
children: incidentContent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={Boolean(incidentAction)}
|
||||
title={
|
||||
incidentAction?.type === 'retry'
|
||||
? '人工重试发货'
|
||||
: incidentAction?.type === 'abnormal'
|
||||
? '上报 Token 失效'
|
||||
: '标记异常已处理'
|
||||
}
|
||||
okText={
|
||||
incidentAction?.type === 'retry'
|
||||
? '确认重试'
|
||||
: incidentAction?.type === 'abnormal'
|
||||
? '确认上报失效'
|
||||
: '确认已处理'
|
||||
}
|
||||
cancelText="取消"
|
||||
confirmLoading={submittingIncident}
|
||||
okButtonProps={{
|
||||
danger: incidentAction?.type === 'abnormal',
|
||||
disabled: !incidentNote.trim(),
|
||||
}}
|
||||
onOk={() => void handleIncidentAction()}
|
||||
onCancel={closeIncidentAction}
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%', marginTop: 8 }}>
|
||||
{incidentAction && (
|
||||
<Alert
|
||||
type={incidentAction.type === 'retry' ? 'warning' : 'info'}
|
||||
showIcon
|
||||
message={`${INCIDENT_CATEGORY_LABELS[incidentAction.incident.category] || incidentAction.incident.category} · ${incidentAction.incident.message}`}
|
||||
/>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{incidentAction?.type === 'retry'
|
||||
? '仅在上游状态已人工核对后使用;系统只允许本次受控重试,失败会重新阻塞。'
|
||||
: incidentAction?.type === 'abnormal'
|
||||
? '该操作会将 Token 标记为已上报失效,请确认卡密已经作废。'
|
||||
: '仅关闭异常事件,不改变 Token 和订单状态。'}
|
||||
</Text>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={incidentNote}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="请填写人工核对结果和处理说明"
|
||||
onChange={(event) => setIncidentNote(event.target.value)}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* oxlint-disable react/only-export-components -- 本文件同时导出单元格组件和共享列构建器。 */
|
||||
import { Button, Dropdown, Popconfirm, Space, Tag, Tooltip, Typography } from 'antd'
|
||||
import {
|
||||
CopyOutlined,
|
||||
@@ -137,7 +138,7 @@ export interface TokenColumnHandlers {
|
||||
onViewRawData: (record: RockTokenItem) => void
|
||||
onResetToken: (id: number) => void
|
||||
onReleaseToken: (record: RockTokenItem) => void
|
||||
onViewStatusLogs: (record: RockTokenItem) => void
|
||||
onViewStatusLogs: (record: RockTokenItem, tab?: 'status' | 'incidents') => void
|
||||
onUpdateRemark: (id: number, remark: string, status?: string) => void
|
||||
onMarkAbnormal: (id: number, remark?: string) => void
|
||||
}
|
||||
@@ -211,24 +212,32 @@ export function buildTokenColumns({
|
||||
color="error"
|
||||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}
|
||||
>
|
||||
⚠️ 使用中超时 {displayDays.toFixed(1)} 天
|
||||
⚠️ 待绑定超时 {displayDays.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
if (usingOverdue) {
|
||||
return (
|
||||
<Tag color="warning" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 扫码使用中 · {displayDays.toFixed(1)} 天
|
||||
● 待绑定 · {displayDays.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 扫码使用中
|
||||
● 待扫码绑定
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'bound': {
|
||||
const redeemPending = Boolean(record.redeem_requested_at && !record.redeemed_at)
|
||||
if (redeemPending) {
|
||||
return (
|
||||
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 发货确认中
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
const boundDays = serverElapsedDays(record.bound_days, record.bound_at)
|
||||
const boundOverdue =
|
||||
record.bound_overdue ?? (boundDays !== null && boundDays >= boundWarnDays)
|
||||
@@ -239,13 +248,13 @@ export function buildTokenColumns({
|
||||
color="volcano"
|
||||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff7a45' }}
|
||||
>
|
||||
⏱ 绑定超时 {displayDays.toFixed(1)} 天
|
||||
⏱ 绑定后未发货 {displayDays.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 已授权绑定
|
||||
● 已绑定待发货
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -285,11 +294,35 @@ export function buildTokenColumns({
|
||||
})()
|
||||
|
||||
// 异常是独立标记:不替换主状态,只在后面追加一个小标签。
|
||||
if (!record.has_anomaly) return statusTag
|
||||
if (!record.has_anomaly && !record.has_open_incident) return statusTag
|
||||
return (
|
||||
<Space direction="vertical" size={4}>
|
||||
{statusTag}
|
||||
<AnomalyBadge record={record} />
|
||||
{record.has_open_incident && (
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ maxWidth: 320, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{record.incident_message || '该 Token 存在需要人工处理的发货异常'}
|
||||
{record.incident_count ? ` · 共 ${record.incident_count} 次` : ''}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
style={{
|
||||
margin: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
padding: '0 6px',
|
||||
color: record.incident_category === 'no_entitlement' ? '#b91c1c' : '#c2410c',
|
||||
background: record.incident_category === 'no_entitlement' ? '#fef2f2' : '#fff7ed',
|
||||
border: record.incident_category === 'no_entitlement' ? '1px solid #fca5a5' : '1px solid #fdba74',
|
||||
}}
|
||||
>
|
||||
{record.incident_category === 'no_entitlement' ? '无权益' : '异常'}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{record.has_anomaly && <AnomalyBadge record={record} />}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
@@ -319,7 +352,7 @@ export function buildTokenColumns({
|
||||
</div>
|
||||
{record.status === 'using' && record.using_at && (
|
||||
<div style={{ fontSize: 11, color: usingColor }}>
|
||||
<span>⏳ 使用中: </span>
|
||||
<span>⏳ 待绑定: </span>
|
||||
{formatDateTime(record.using_at)}
|
||||
{usingDays !== null ? `(${floorDays(usingDays).toFixed(1)} 天)` : ''}
|
||||
</div>
|
||||
@@ -607,11 +640,16 @@ export function buildTokenColumns({
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => onViewStatusLogs(record)}
|
||||
onClick={() => onViewStatusLogs(record, 'status')}
|
||||
>
|
||||
历史
|
||||
</Button>
|
||||
)
|
||||
const incidentButton = record.has_open_incident ? (
|
||||
<Button size="small" danger onClick={() => onViewStatusLogs(record, 'incidents')}>
|
||||
处理异常
|
||||
</Button>
|
||||
) : null
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
@@ -625,6 +663,7 @@ export function buildTokenColumns({
|
||||
<Space size={4} wrap>
|
||||
{refreshButton}
|
||||
{upstreamButton}
|
||||
{incidentButton}
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
@@ -671,6 +710,7 @@ export function buildTokenColumns({
|
||||
<Space size={4} wrap>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>已失效作废</Text>
|
||||
{upstreamButton}
|
||||
{incidentButton}
|
||||
<Popconfirm
|
||||
title="确定强制恢复为可用库存?"
|
||||
description="警告:该链接曾标记为已上报失效。若上游已作废该卡密,重新放回库存将导致下一单提货失败!请确保卡密真实可用。"
|
||||
@@ -692,6 +732,7 @@ export function buildTokenColumns({
|
||||
<Space size={4} wrap>
|
||||
{refreshButton}
|
||||
{upstreamButton}
|
||||
{incidentButton}
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => onReleaseToken(record)}>
|
||||
释放回库存
|
||||
</Button>
|
||||
@@ -700,10 +741,21 @@ export function buildTokenColumns({
|
||||
)
|
||||
}
|
||||
if (record.status === 'bound') {
|
||||
const redeemPending = Boolean(record.redeem_requested_at && !record.redeemed_at)
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{refreshButton}
|
||||
{upstreamButton}
|
||||
{incidentButton}
|
||||
{redeemPending ? (
|
||||
<Tag color="processing" style={{ margin: 0 }}>
|
||||
发货确认中
|
||||
</Tag>
|
||||
) : (
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => onReleaseToken(record)}>
|
||||
释放回库存
|
||||
</Button>
|
||||
)}
|
||||
{historyButton}
|
||||
</Space>
|
||||
)
|
||||
@@ -711,6 +763,7 @@ export function buildTokenColumns({
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{upstreamButton}
|
||||
{incidentButton}
|
||||
{historyButton}
|
||||
</Space>
|
||||
)
|
||||
|
||||
@@ -8,13 +8,14 @@ export interface UidEditState {
|
||||
// Token 主状态中文名,状态历史与释放弹窗共用。
|
||||
export const ROCK_TOKEN_STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '扫码使用中',
|
||||
bound: '已授权绑定',
|
||||
using: '待扫码绑定',
|
||||
bound: '已绑定待发货',
|
||||
redeemed: '已交付发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
using_overdue: '使用超时',
|
||||
bound_overdue: '绑定超时',
|
||||
using_overdue: '待绑定超时',
|
||||
bound_overdue: '绑定后未发货',
|
||||
no_entitlement: '无权益',
|
||||
}
|
||||
|
||||
export function rockTokenStatusLabel(status?: string): string {
|
||||
|
||||
Reference in New Issue
Block a user