feat(rock): 发货记录表格新增时间与备注列并支持上报失效联动

- 做了什么:
  1. 数据库新增 013_rock_tokens_remark.sql 迁移,rock_tokens 表补充 remark 字段并在 ListTokens 中支持备注模糊检索;
  2. 后端扩展 UpdateTokenRemark、MarkTokenAbnormal 接口,同时优化 ResetMismatchToken 支持 mismatch 与 abnormal 状态的解冻重置;
  3. 前端 RockTokenTable 新增「流转时间」列(入库/绑错/发货/绑定/提货)与「备注」列(TokenRemarkCell 浮窗快捷预设选择与自定义输入);
  4. 快捷选择「已上报」或点击「上报失效」自动将链接标记为 abnormal 作废,从可用库存池中剔除,防止被新订单领走;
  5. 优化操作列与顶部筛选栏:mismatch 增加「上报失效」与警示重置;abnormal 状态明确显示已失效并提供强制恢复;筛选栏新增「已上报失效」Tab。

- 为什么:
  解决绑定错误上报 0868 上游后无法记录备注、且原「解冻重置」容易误将已作废卡密放回可用库存池导致后续订单报错的问题。

- 验证方式:
  1. 后端单元测试 TestRockService_TokenRemarkAndAbnormal 与 TestRock 测试集通过;
  2. make fmt、go vet 通过;
  3. 前端 oxlint 检查通过 (0 errors, 0 warnings),npm run build 生产构建通过。
This commit is contained in:
yml2213
2026-10-08 20:13:25 +08:00
parent fcbd7715c7
commit e293cebaf5
13 changed files with 656 additions and 15 deletions
+5
View File
@@ -330,6 +330,7 @@ export interface RockTokenItem {
target_currency_quantity?: number
mismatch_at?: string
mismatch_reason?: string
remark?: string
claimed_at?: string
bound_at?: string
redeemed_at?: string
@@ -348,6 +349,10 @@ export const rockApi = {
request.post(`/platform/rock/tokens/${id}/expected-uid`, { expected_uid: expectedUid }).then((r) => r.data.data as { message?: string; status?: string }),
resetToken: (id: number) => request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data),
releaseToken: (id: number) => request.post(`/platform/rock/tokens/${id}/release`).then((r) => r.data.data),
updateRemark: (id: number, data: { remark: string; status?: string }) =>
request.post(`/platform/rock/tokens/${id}/remark`, data).then((r) => r.data.data as { message: string; token: RockTokenItem }),
markAbnormal: (id: number, remark?: string) =>
request.post(`/platform/rock/tokens/${id}/abnormal`, { remark }).then((r) => r.data.data as { message: string; token: RockTokenItem }),
}
@@ -59,6 +59,11 @@ export function RockTokenFilterBar({
<span>绑定错误</span>
</Badge>
</Radio.Button>
<Radio.Button value="abnormal">
<Badge count={stats?.abnormal_stock ?? 0} offset={[6, -2]} size="small" style={{ backgroundColor: '#64748b' }}>
<span>已上报失效</span>
</Badge>
</Radio.Button>
</Radio.Group>
<Button icon={<ReloadOutlined />} onClick={onRefresh} loading={loadingTokens}>
刷新
@@ -38,11 +38,11 @@ export function RockTokenTable({
onReloadData,
onReloadTokens,
}: RockTokenTableProps) {
// 人工重置解冻 Mismatch Token
// 人工重置解冻 Mismatch 或 Abnormal Token
const handleResetToken = async (id: number) => {
try {
await rockApi.resetToken(id)
message.success('已解除绑定异常,重置为可用库存')
message.success('已重置并释放为可用库存')
onReloadData()
onReloadTokens()
} catch (e) {
@@ -50,6 +50,30 @@ export function RockTokenTable({
}
}
// 更新 Token 备注
const handleUpdateRemark = async (id: number, remark: string, status?: string) => {
try {
await rockApi.updateRemark(id, { remark, status })
message.success('备注已更新')
onReloadData()
onReloadTokens()
} catch (e) {
message.error('更新备注失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
}
// 快捷标记为已上报失效(作废,不可再用)
const handleMarkAbnormal = async (id: number, remark?: string) => {
try {
await rockApi.markAbnormal(id, remark)
message.success('已标记为上报失效,该连接已作废且不可使用')
onReloadData()
onReloadTokens()
} catch (e) {
message.error('标记失效失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
}
// 买家未扫码绑定时,把占用中的提货链接放回库存
const handleReleaseToken = async (id: number) => {
try {
@@ -131,6 +155,8 @@ export function RockTokenTable({
onEditUid,
onResetToken: handleResetToken,
onReleaseToken: handleReleaseToken,
onUpdateRemark: handleUpdateRemark,
onMarkAbnormal: handleMarkAbnormal,
})
return (
@@ -139,7 +165,7 @@ export function RockTokenTable({
columns={columns}
dataSource={tokens}
loading={loadingTokens}
scroll={{ x: 1100 }}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize,
@@ -0,0 +1,241 @@
import { useState } from 'react'
import { Button, Divider, Input, Popover, Space, Tag, Typography } from 'antd'
import {
CheckOutlined,
CloseCircleOutlined,
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
StopOutlined,
} from '@ant-design/icons'
import type { RockTokenItem } from '../../api'
const { Text } = Typography
export interface TokenRemarkCellProps {
record: RockTokenItem
onUpdateRemark: (id: number, remark: string, status?: string) => void
onMarkAbnormal: (id: number, remark?: string) => void
}
export function TokenRemarkCell({ record, onUpdateRemark, onMarkAbnormal }: TokenRemarkCellProps) {
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState(record.remark || '')
const handleQuickMarkAbnormal = (reason: string) => {
onMarkAbnormal(record.id, reason)
setOpen(false)
}
const handleQuickUpdate = (remarkText: string) => {
onUpdateRemark(record.id, remarkText)
setOpen(false)
}
const handleSaveCustom = () => {
onUpdateRemark(record.id, inputValue.trim())
setOpen(false)
}
const handleClear = () => {
onUpdateRemark(record.id, '')
setInputValue('')
setOpen(false)
}
const content = (
<div style={{ width: 280 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
borderBottom: '1px solid #f1f5f9',
paddingBottom: 6,
}}
>
<span style={{ fontWeight: 600, fontSize: 13, color: '#0f172a' }}>快速设置备注</span>
<Text type="secondary" style={{ fontSize: 11 }}>
#{record.upstream_token_id || record.id}
</Text>
</div>
<div style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 11 }}>
快捷选择(点击直接生效):
</Text>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={5}>
<Button
size="small"
danger
block
style={{
textAlign: 'left',
display: 'flex',
alignItems: 'center',
background: '#fef2f2',
borderColor: '#fca5a5',
}}
icon={<StopOutlined />}
onClick={() => handleQuickMarkAbnormal('已上报错误 (卡密失效)')}
>
<span style={{ fontWeight: 600 }}>已上报错误 (标记失效)</span>
</Button>
<Button
size="small"
block
style={{
textAlign: 'left',
display: 'flex',
alignItems: 'center',
borderColor: '#fdba74',
color: '#c2410c',
background: '#fff7ed',
}}
icon={<ExclamationCircleOutlined />}
onClick={() => handleQuickMarkAbnormal('绑定错误-已上报上游')}
>
<span>绑定错误 (已上报上游)</span>
</Button>
<Button
size="small"
block
style={{ textAlign: 'left', display: 'flex', alignItems: 'center' }}
icon={<EditOutlined />}
onClick={() => handleQuickUpdate('买家UID填错 (待核对)')}
>
<span>买家UID填错 (待核对)</span>
</Button>
<Button
size="small"
block
style={{ textAlign: 'left', display: 'flex', alignItems: 'center' }}
icon={<EditOutlined />}
onClick={() => handleQuickUpdate('上报处理中 (等待答复)')}
>
<span>上报处理中 (等待答复)</span>
</Button>
</Space>
<Divider style={{ margin: '10px 0 8px 0' }} />
<div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 11 }}>
自定义备注内容:
</Text>
</div>
<Space.Compact style={{ width: '100%' }}>
<Input
size="small"
placeholder="输入自定义备注..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={handleSaveCustom}
/>
<Button size="small" type="primary" icon={<CheckOutlined />} onClick={handleSaveCustom}>
保存
</Button>
</Space.Compact>
</div>
{record.remark ? (
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Button
size="small"
type="link"
danger
icon={<DeleteOutlined />}
onClick={handleClear}
style={{ padding: 0, fontSize: 11 }}
>
清空当前备注
</Button>
</div>
) : null}
</div>
)
const isAbnormalOrReported =
record.status === 'abnormal' || (record.remark && record.remark.includes('上报'))
if (!record.remark) {
return (
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={(v) => {
setOpen(v)
if (v) setInputValue('')
}}
placement="bottomLeft"
>
<span
style={{
cursor: 'pointer',
fontSize: 12,
color: '#64748b',
padding: '2px 6px',
borderRadius: 4,
border: '1px dashed #cbd5e1',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.2s',
}}
title="点击快速设置备注"
>
<EditOutlined style={{ fontSize: 11 }} />
添加备注
</span>
</Popover>
)
}
return (
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={(v) => {
setOpen(v)
if (v) setInputValue(record.remark || '')
}}
placement="bottomLeft"
>
<div
style={{
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
maxWidth: 220,
}}
title="点击修改或快速设置备注"
>
<Tag
color={isAbnormalOrReported ? 'error' : 'blue'}
style={{
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
margin: 0,
fontSize: 12,
padding: '2px 6px',
border: isAbnormalOrReported ? '1px solid #fca5a5' : undefined,
}}
icon={isAbnormalOrReported ? <CloseCircleOutlined /> : <EditOutlined />}
>
{record.remark}
</Tag>
</div>
</Popover>
)
}
@@ -1,7 +1,9 @@
import { Button, Popconfirm, Space, Tag, Tooltip, Typography } from 'antd'
import { CopyOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons'
import { CopyOutlined, EyeOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import type { RockTokenItem } from '../../api'
import { formatDateTime } from '../../utils/time'
import { TokenRemarkCell } from './TokenRemarkCell'
import type { UidEditState } from './types'
const { Text } = Typography
@@ -13,6 +15,8 @@ export interface TokenColumnHandlers {
onEditUid: (state: UidEditState) => void
onResetToken: (id: number) => void
onReleaseToken: (id: number) => void
onUpdateRemark: (id: number, remark: string, status?: string) => void
onMarkAbnormal: (id: number, remark?: string) => void
}
export function buildTokenColumns({
@@ -22,6 +26,8 @@ export function buildTokenColumns({
onEditUid,
onResetToken,
onReleaseToken,
onUpdateRemark,
onMarkAbnormal,
}: TokenColumnHandlers): ColumnsType<RockTokenItem> {
return [
{
@@ -98,11 +104,62 @@ export function buildTokenColumns({
⚠️ 绑定错误 (阻断)
</Tag>
)
case 'abnormal':
return (
<Tag
color="default"
style={{
fontWeight: 600,
padding: '2px 8px',
color: '#b91c1c',
background: '#fef2f2',
border: '1px solid #fca5a5',
}}
>
🚫 已上报失效
</Tag>
)
default:
return <Tag>{status}</Tag>
}
},
},
{
title: '流转时间',
width: 175,
render: (_, record) => (
<Space direction="vertical" size={2}>
<div style={{ fontSize: 12, color: '#334155' }}>
<span style={{ color: '#94a3b8' }}>入库: </span>
{formatDateTime(record.created_at)}
</div>
{record.mismatch_at && (
<div style={{ fontSize: 11, color: '#e11d48' }}>
<span>⚠️ 绑错: </span>
{formatDateTime(record.mismatch_at)}
</div>
)}
{record.redeemed_at && (
<div style={{ fontSize: 11, color: '#16a34a' }}>
<span>✅ 发货: </span>
{formatDateTime(record.redeemed_at)}
</div>
)}
{!record.redeemed_at && record.bound_at && (
<div style={{ fontSize: 11, color: '#0284c7' }}>
<span>🔗 绑定: </span>
{formatDateTime(record.bound_at)}
</div>
)}
{!record.bound_at && !record.mismatch_at && record.claimed_at && (
<div style={{ fontSize: 11, color: '#6366f1' }}>
<span>📦 提货: </span>
{formatDateTime(record.claimed_at)}
</div>
)}
</Space>
),
},
{
title: '关联业务工单',
dataIndex: 'assigned_order_no',
@@ -179,6 +236,17 @@ export function buildTokenColumns({
)
},
},
{
title: '备注',
width: 200,
render: (_, record) => (
<TokenRemarkCell
record={record}
onUpdateRemark={onUpdateRemark}
onMarkAbnormal={onMarkAbnormal}
/>
),
},
{
title: '发货结果图片',
key: 'voucher',
@@ -273,7 +341,7 @@ export function buildTokenColumns({
{
title: '操作',
key: 'action',
width: 168,
width: 180,
fixed: 'right',
render: (_, record) => {
if (record.status === 'mismatch') {
@@ -294,19 +362,50 @@ export function buildTokenColumns({
修改 UID
</Button>
<Popconfirm
title="确定解冻重置该 Token?"
description="重置后将清除原订单绑定与错误标记,重新释放为可用通用库存。已扫码的链接放回库存后,其他订单领走就会废掉。"
okText="确认解冻"
title="确定将该链接标记为已上报失效?"
description="上报给上游后该链接将永久作废,系统不会再将其作为可用库存分配给任何新订单。"
okText="确认上报失效"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => onMarkAbnormal(record.id, '绑定错误-已上报上游 (已失效)')}
>
<Button size="small" danger icon={<StopOutlined />}>
上报失效
</Button>
</Popconfirm>
<Popconfirm
title="确定解冻重置该 Token 回库存?"
description="注意:仅在确认卡密未作废、未上报上游时操作。重置后将清除原订单绑定与错误标记,重新释放为可用库存。若已上报上游请点击【上报失效】。"
okText="确认重置回库存"
cancelText="取消"
onConfirm={() => onResetToken(record.id)}
>
<Button size="small" danger ghost icon={<UndoOutlined />}>
<Button size="small" ghost style={{ color: '#64748b', borderColor: '#cbd5e1' }} icon={<UndoOutlined />}>
解冻重置
</Button>
</Popconfirm>
</Space>
)
}
if (record.status === 'abnormal') {
return (
<Space direction="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 11 }}>已失效作废</Text>
<Popconfirm
title="确定强制恢复为可用库存?"
description="警告:该链接曾标记为已上报失效。若上游已作废该卡密,重新放回库存将导致下一单提货失败!请确保卡密真实可用。"
okText="强制恢复回库存"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => onResetToken(record.id)}
>
<Button size="small" icon={<UndoOutlined />} style={{ fontSize: 11 }}>
恢复回库存
</Button>
</Popconfirm>
</Space>
)
}
if (record.status === 'using' && !record.bound_at && !record.actual_role_id) {
return (
<Popconfirm