feat: 完善 Rock 发货异常闭环与绑定判定

This commit is contained in:
yml
2026-11-14 23:58:57 +08:00
parent 9353b439a7
commit 6a87fe5228
45 changed files with 3497 additions and 389 deletions
+61
View File
@@ -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`)
+2 -2
View File
@@ -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>
)
+5 -4
View File
@@ -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 {