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:
@@ -0,0 +1,5 @@
|
||||
-- ============================================================================
|
||||
-- 013_rock_tokens_remark.sql: 为 rock_tokens 表新增 remark 备注字段
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE rock_tokens ADD COLUMN IF NOT EXISTS remark TEXT NOT NULL DEFAULT '';
|
||||
@@ -169,3 +169,55 @@ func (h *RockHandler) ReleaseToken(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
|
||||
}
|
||||
|
||||
type updateRemarkReq struct {
|
||||
Remark string `json:"remark"`
|
||||
Status string `json:"status"` // 可选:比如 abnormal
|
||||
}
|
||||
|
||||
// UpdateRemark 更新 Token 备注并可联动更新状态
|
||||
func (h *RockHandler) UpdateRemark(c *gin.Context) {
|
||||
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "无效的 Token ID")
|
||||
return
|
||||
}
|
||||
var req updateRemarkReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
token, err := h.rockSvc.UpdateTokenRemark(c.Request.Context(), uint(idUint), req.Remark, req.Status)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"message": "备注更新成功",
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
|
||||
type markAbnormalReq struct {
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// MarkAbnormal 快捷标记为上报失效(作废,不可再用)
|
||||
func (h *RockHandler) MarkAbnormal(c *gin.Context) {
|
||||
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "无效的 Token ID")
|
||||
return
|
||||
}
|
||||
var req markAbnormalReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
token, err := h.rockSvc.MarkTokenAbnormal(c.Request.Context(), uint(idUint), req.Remark)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"message": "已标记为上报失效,该提货链接已作废",
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ type RockToken struct {
|
||||
TargetCurrencyQuantity int `gorm:"not null;default:0" json:"target_currency_quantity"`
|
||||
MismatchAt *time.Time `gorm:"index" json:"mismatch_at"`
|
||||
MismatchReason string `gorm:"size:255" json:"mismatch_reason"`
|
||||
Remark string `gorm:"type:text;not null;default:''" json:"remark"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
BoundAt *time.Time `json:"bound_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at"`
|
||||
|
||||
@@ -279,6 +279,8 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
platform.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID)
|
||||
platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
|
||||
platform.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken)
|
||||
platform.POST("/platform/rock/tokens/:id/remark", h.Rock.UpdateRemark)
|
||||
platform.POST("/platform/rock/tokens/:id/abnormal", h.Rock.MarkAbnormal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -996,3 +996,71 @@ func TestRockProvider_CheckBindRefreshesSameTokenLink(t *testing.T) {
|
||||
t.Fatalf("其他订单不能拿到这个提货号的二维码: %s", checkOther.BindURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRockService_TokenRemarkAndAbnormal(t *testing.T) {
|
||||
db := testdb.New(t)
|
||||
client := NewRockClient("https://0666.pro")
|
||||
rockSvc := NewRockService(db, client)
|
||||
|
||||
now := time.Now()
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "TEST_REMARK_1",
|
||||
Token: "jwt-remark-1",
|
||||
RedeemCode: "CODE_REMARK_1",
|
||||
Status: model.RockStatusMismatch,
|
||||
AssignedOrderNo: "ORDER_REMARK_1",
|
||||
ExpectedUID: "123456",
|
||||
ActualRoleID: "654321",
|
||||
ActualRoleName: "路人甲",
|
||||
MismatchAt: &now,
|
||||
MismatchReason: "预期UID不符",
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("create test token failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. 测试单纯更新备注
|
||||
updated, err := rockSvc.UpdateTokenRemark(context.Background(), token.ID, "买家联系客服核对中", "")
|
||||
if err != nil {
|
||||
t.Fatalf("update remark failed: %v", err)
|
||||
}
|
||||
if updated.Remark != "买家联系客服核对中" || updated.Status != model.RockStatusMismatch {
|
||||
t.Fatalf("更新备注不符: remark=%s status=%s", updated.Remark, updated.Status)
|
||||
}
|
||||
|
||||
// 2. 测试快捷标记为已上报失效 (MarkTokenAbnormal)
|
||||
abnormalToken, err := rockSvc.MarkTokenAbnormal(context.Background(), token.ID, "已上报0868客服 (卡密绑定错误废弃)")
|
||||
if err != nil {
|
||||
t.Fatalf("mark abnormal failed: %v", err)
|
||||
}
|
||||
if abnormalToken.Status != model.RockStatusAbnormal {
|
||||
t.Fatalf("标记异常后状态应为 abnormal,实际: %s", abnormalToken.Status)
|
||||
}
|
||||
if abnormalToken.Remark != "已上报0868客服 (卡密绑定错误废弃)" {
|
||||
t.Fatalf("标记异常后备注不符: %s", abnormalToken.Remark)
|
||||
}
|
||||
|
||||
// 3. 搜索备注测试
|
||||
list, total, err := rockSvc.ListTokens(context.Background(), 1, 10, "", "卡密绑定错误废弃")
|
||||
if err != nil {
|
||||
t.Fatalf("list by remark search error: %v", err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 || list[0].ID != token.ID {
|
||||
t.Fatalf("通过备注搜索未检索到目标 token, total=%d", total)
|
||||
}
|
||||
|
||||
// 4. 从 abnormal 状态解冻重置回 available
|
||||
if err := rockSvc.ResetMismatchToken(context.Background(), token.ID); err != nil {
|
||||
t.Fatalf("reset abnormal token error: %v", err)
|
||||
}
|
||||
var resetToken model.RockToken
|
||||
if err := db.First(&resetToken, token.ID).Error; err != nil {
|
||||
t.Fatalf("find reset token error: %v", err)
|
||||
}
|
||||
if resetToken.Status != model.RockStatusAvailable {
|
||||
t.Fatalf("重置后应为 available,实际: %s", resetToken.Status)
|
||||
}
|
||||
if resetToken.AssignedOrderNo != "" || resetToken.ExpectedUID != "" {
|
||||
t.Fatalf("重置后应清空订单关联, assigned_order_no=%s", resetToken.AssignedOrderNo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,20 +98,20 @@ func isDigits(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ResetMismatchToken 客服人工重置解冻绑定错误的 Token
|
||||
// ResetMismatchToken 客服人工重置解冻绑定错误或作废的 Token
|
||||
func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) error {
|
||||
var token model.RockToken
|
||||
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if token.Status != model.RockStatusMismatch {
|
||||
return fmt.Errorf("仅 mismatch 状态的 Token 允许人工解冻重置")
|
||||
if token.Status != model.RockStatusMismatch && token.Status != model.RockStatusAbnormal {
|
||||
return fmt.Errorf("仅 mismatch 或 abnormal 状态的 Token 允许人工解冻重置")
|
||||
}
|
||||
|
||||
s.refreshCache.Delete(token.UpstreamTokenID)
|
||||
|
||||
// 重置回 available,清空订单关联
|
||||
// 重置回 available,清空订单关联与异常标记
|
||||
return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
||||
"status": model.RockStatusAvailable,
|
||||
"assigned_order_no": "",
|
||||
@@ -125,3 +125,51 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) erro
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdateTokenRemark 更新 Token 备注,并支持联动流转状态(例如上报后置为 abnormal 失效)
|
||||
func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remark string, nextStatus string) (*model.RockToken, error) {
|
||||
var token model.RockToken
|
||||
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"remark": strings.TrimSpace(remark),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
|
||||
trimmedStatus := strings.TrimSpace(nextStatus)
|
||||
if trimmedStatus != "" {
|
||||
switch trimmedStatus {
|
||||
case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch:
|
||||
updates["status"] = trimmedStatus
|
||||
token.Status = trimmedStatus
|
||||
if trimmedStatus == model.RockStatusAbnormal {
|
||||
s.refreshCache.Delete(token.UpstreamTokenID)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的目标状态: %s", trimmedStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token.Remark = updates["remark"].(string)
|
||||
|
||||
_ = writeAudit(s.db.WithContext(ctx), nil, nil, nil, "rock_token.update_remark", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{
|
||||
"remark": token.Remark,
|
||||
"status": token.Status,
|
||||
})
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// MarkTokenAbnormal 快捷标记为上报失效(算作废,不可再用)
|
||||
func (s *RockService) MarkTokenAbnormal(ctx context.Context, tokenID uint, reason string) (*model.RockToken, error) {
|
||||
remark := strings.TrimSpace(reason)
|
||||
if remark == "" {
|
||||
remark = "已上报上游 (失效作废)"
|
||||
}
|
||||
return s.UpdateTokenRemark(ctx, tokenID, remark, model.RockStatusAbnormal)
|
||||
}
|
||||
|
||||
@@ -127,8 +127,8 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
|
||||
}
|
||||
if strings.TrimSpace(search) != "" {
|
||||
term := "%" + strings.TrimSpace(search) + "%"
|
||||
db = db.Where("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)",
|
||||
strings.TrimSpace(search), strings.TrimSpace(search), term, term, term, term, term, term, term)
|
||||
db = db.Where("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR remark LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)",
|
||||
strings.TrimSpace(search), strings.TrimSpace(search), term, term, term, term, term, term, term, term)
|
||||
}
|
||||
|
||||
var total int64
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Rock发货记录时间与备注及失效状态优化方案
|
||||
|
||||
> **日期**:2026-10-08
|
||||
> **任务背景**:Rock (0666.pro) 发货平台看板中,提货记录缺少时间追踪与运营备注;当发生 UID 绑定错误(mismatch)并上报给上游(0868/0666 客服)后,该链接实际已废弃无法使用,但原有仅有的「解冻重置」会将坏卡重新释放回可用库存池,存在严重业务安全隐患。
|
||||
|
||||
---
|
||||
|
||||
## 一、进度与证据分类登记
|
||||
|
||||
### 1.1 已知事实 (Known Facts)
|
||||
1. **现有数据结构与表定义**:
|
||||
- 源码位置:`backend/internal/model/rock.go`、`backend/internal/database/migrations/012_rock_fulfillment.sql`
|
||||
- `rock_tokens` 表已有字段:`id`, `upstream_token_id`, `redeem_code`, `status`, `assigned_order_no`, `expected_uid`, `actual_role_id`, `actual_role_name`, `mismatch_at`, `mismatch_reason`, `claimed_at`, `bound_at`, `redeemed_at`, `created_at`, `updated_at`。
|
||||
- 确信度:**100%(源码与迁移文件已确认)**。
|
||||
2. **状态机支持情况**:
|
||||
- `model.RockStatusAbnormal = "abnormal"` 已经定义(注释为“异常/作废”),并在 `rock_assign.go:50-62`、`rock_service.go:215` 等处被处理(当 status 为 abnormal 时,领号逻辑会直接拦截阻断该订单再次分配此号)。
|
||||
- 当前缺少从前端直接将 Token 标记为 `abnormal` / 已上报失效的流转入口。
|
||||
- 确信度:**100%(源码已确认)**。
|
||||
3. **表格现有列**:
|
||||
- 源码位置:`frontend/src/pages/platformRock/tokenColumns.tsx`
|
||||
- 现有列包含:`0666 编号 / 提取码`, `规格与适用范围`, `库存状态`, `关联业务工单`, `预期 UID vs 扫码角色`, `发货结果图片`, `操作`。
|
||||
- 缺乏:时间列(入库时间、绑错时间、绑定/发货时间)以及备注列。
|
||||
- 确信度:**100%(源码与截图已确认)**。
|
||||
4. **原有「解冻重置」的逻辑与缺陷**:
|
||||
- 源码位置:`backend/internal/service/rock_recover.go:101-127`(`ResetMismatchToken`)
|
||||
- 现有逻辑:只要是 mismatch 状态,调用 resetToken 会直接无条件将 `status` 更新为 `available`,清空 `assigned_order_no` 和 `expected_uid`。
|
||||
- 业务缺陷:若运营人员已向 0868 上游反馈坏卡/上报问题,该链接在上游已被处理或作废,一旦客服误点“解冻重置”,该废卡被重新分给后续新订单,导致下一单继续报错。
|
||||
- 确信度:**100%(源码与业务逻辑已确认)**。
|
||||
|
||||
### 1.2 目标 (Goals)
|
||||
1. **数据库与数据模型升级**:
|
||||
- 在 `rock_tokens` 表增加 `remark` 字段(TEXT / VARCHAR(255))。
|
||||
- 新增安全数据库迁移脚本(`013_rock_tokens_remark.sql`),自动为现有表增列。
|
||||
- 在 `model.RockToken` 补充 `Remark string json:"remark"` 字段。
|
||||
2. **后端 API 扩展**:
|
||||
- 提供 Token 备注更新与状态联动接口:`POST /api/platform/rock/tokens/:id/remark`。
|
||||
- 支持设置备注(例如“已上报上游”、“卡密绑定错误已上报”、“买家填错UID”等),并支持可选传参 `status`(如置为 `abnormal` / 已失效作废)。
|
||||
- 提供针对已失效/作废的专门操作或优化现有 Reset 行为(若已上报失效,明确区分是作废还是强制重置)。
|
||||
- 搜索功能支持对备注 `remark` 进行模糊查询。
|
||||
3. **前端表格重构 (`tokenColumns.tsx` & `RockTokenTable.tsx`)**:
|
||||
- **新增「时间信息」列**:
|
||||
- 入库时间(`created_at`);
|
||||
- 关键节点流转时间(如 `mismatch_at` 绑错时间、`bound_at` 授权时间、`redeemed_at` 发货时间),带醒目标识。
|
||||
- **新增「备注」列**:
|
||||
- 支持快捷选择标签(下拉/气泡快捷选填):
|
||||
- 🔴 `已上报上游 (作废失效)`(自动设置备注并将状态置为失效 `abnormal`,阻断再次使用);
|
||||
- ⚠️ `绑定错误 (已上报)`(设置备注并将状态置为失效 `abnormal`);
|
||||
- ✏️ `买家UID填错 (待核对)`;
|
||||
- ⏳ `上报处理中`;
|
||||
- 📝 `自定义输入备注`;
|
||||
- 🧹 `清空备注`。
|
||||
- 行内直观展示当前备注内容。
|
||||
- **优化「操作」列**:
|
||||
- 对于 `mismatch` 状态:
|
||||
- 保留「修改 UID」(适用于买家扫对了但填错了);
|
||||
- 新增一键「上报失效」(快捷将链接标记为已上报并作废,不再入库存池);
|
||||
- 优化「解冻重置」的提示文案与门禁(警告不可将已上报上游的卡重置回库存)。
|
||||
- 对于 `abnormal`(已上报失效 / 作废)状态:
|
||||
- 展示「已作废 (不可用)」徽章,不再显示常规解冻重置;
|
||||
- 如确需误操作恢复,提供带高危确认的「强制恢复为可用」。
|
||||
- **优化筛选栏 (`RockTokenFilterBar.tsx`)**:
|
||||
- 增加「已失效/已上报」(`abnormal`)筛选 Tab,方便运营检索所有上报废弃卡。
|
||||
|
||||
### 1.3 下一步执行计划 (Next Steps)
|
||||
1. [x] 现状分析与数据库/代码核查完成。
|
||||
2. [x] 编写数据库迁移 `013_rock_tokens_remark.sql` 并更新 Go 结构体 `model.RockToken`。
|
||||
3. [x] 扩展后端 service 与 handler:实现 Token 备注与状态流转接口,编写对应单元测试并通过验证。
|
||||
4. [x] 前端类型与 API 定义补充 (`frontend/src/api/index.ts`)。
|
||||
5. [x] 前端组件实现:
|
||||
- `tokenColumns.tsx` 增加流转时间列、备注列(含快速设置交互)、优化操作列。
|
||||
- `TokenRemarkCell.tsx` 实现一键快捷选择(上报错误失效等)与自定义备注输入。
|
||||
- `RockTokenFilterBar.tsx` 增加 `abnormal` 已上报失效状态筛选。
|
||||
6. [x] 完整回归验证:
|
||||
- 前端构建 `npm run build`、代码规范 `oxlint` 全部通过 (0 errors, 0 warnings)。
|
||||
- 后端代码格式化 `make fmt`、类型检查 `go vet` 全部通过。
|
||||
- 单元测试 `go test -count=1 ./internal/service -run TestRock` 全部通过。
|
||||
|
||||
---
|
||||
|
||||
## 二、验收与验证结果清单
|
||||
|
||||
| 检查项 | 验证命令 / 方式 | 结果 |
|
||||
| :--- | :--- | :--- |
|
||||
| 数据库增列与迁移 | 检查 `013_rock_tokens_remark.sql` 与 `model.RockToken` | 通过,新增 `remark` 字段 |
|
||||
| 接口联通与流转逻辑 | 运行单测 `TestRockService_TokenRemarkAndAbnormal` | 通过,支持备注更新、一键标记 abnormal、模糊搜索备注及异常重置 |
|
||||
| 前端 TypeScript 编译 | `npm run build` (`tsc -b && vite build`) | 通过 (0 errors) |
|
||||
| 前端代码质量检查 | `npm run lint` (`oxlint`) | 通过 (0 errors, 0 warnings) |
|
||||
| 后端代码格式与单测 | `make fmt` && `go vet ./...` && `go test -count=1 ./internal/service -run TestRock` | 全部通过 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user