feat(worker-platform): 支持后台人工调级锁定等级与解除自动升级限制

- 做了什么:
  1. 新增数据库迁移 098_worker_level_lock.sql,在 worker_users 表增加 is_level_locked、level_locked_at、level_locked_reason、level_locked_by 字段;
  2. 后端自动升级函数 maybeUpgradeWorkerLevelWithClient 和判定函数 shouldApplyAutomaticWorkerLevel 增加 is_level_locked 守卫,被锁定的打手跳过自动升级;
  3. 后台打手审核调级服务 reviewAdminWorkerUser 支持等级锁定参数与变更记录,人工调级时自动写入 worker_level_logs 流水,并提供 updateAdminWorkerLevelLock 专用接口;
  4. 前端打手列表等级列展示「已锁定」状态及 Tooltip 原因,调级弹窗支持「锁定自动升级」开关与原因输入,操作列提供「解除限制」快捷操作;
  5. 打手详情抽屉补充展示等级锁定说明。
- 为什么:
  解决打手被人工调整等级(如 VIP5 调至 VIP4)后,因历史累计完成单数满足门槛导致后续结算再次自动升级回高等级的问题,并支持后续随时解除限制恢复自动升级。
- 验证方式:
  1. 后端新增 shouldApplyAutomaticWorkerLevel 与 mapWorkerUser 单元测试,运行 npm run check(670 测试全过,类型与 SQL guard 验证通过);
  2. 前端新增 worker-level-lock.test.ts 单元测试,运行 npm run check(130 测试全过,typecheck 与 build 验证通过)。
This commit is contained in:
yml2213
2026-10-10 13:44:57 +08:00
parent 687bb40db1
commit 85fc2eaa7c
15 changed files with 395 additions and 11 deletions
@@ -153,7 +153,23 @@ function OverviewSection({ workerId }: { workerId: number }) {
<Descriptions.Item label="昵称">{worker.displayName || '-'}</Descriptions.Item>
<Descriptions.Item label="账号">{worker.username}</Descriptions.Item>
<Descriptions.Item label="手机号">{worker.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="等级">{worker.level?.name || '-'}</Descriptions.Item>
<Descriptions.Item label="等级">
<Space size={6} wrap>
<span>{worker.level?.name || '-'}</span>
{worker.isLevelLocked ? <Tag color="warning">已锁定自动升级</Tag> : null}
</Space>
</Descriptions.Item>
{worker.isLevelLocked ? (
<Descriptions.Item label="等级锁定说明" span={2}>
<Typography.Text type="secondary">
{worker.levelLockedReason ? `原因: ${worker.levelLockedReason}` : '人工调级锁定'}
{worker.levelLockedBy ? ` · 操作人: ${worker.levelLockedBy}` : ''}
{worker.levelLockedAt
? ` · 时间: ${formatAdminDateTime(worker.levelLockedAt)}`
: ''}
</Typography.Text>
</Descriptions.Item>
) : null}
<Descriptions.Item label="邀请人">
{worker.inviter ? `${worker.inviter.displayName || worker.inviter.username}` : '-'}
</Descriptions.Item>
@@ -1,4 +1,4 @@
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { LockOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
@@ -9,6 +9,7 @@ import {
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
@@ -29,6 +30,7 @@ import {
fetchAdminWorkerWithdrawalAccounts,
resetAdminWorkerPassword,
reviewAdminWorkerUser,
updateAdminWorkerLevelLock,
saveAdminWorkerFinanceConfig,
saveAdminWorkerPersonalDepositFreeAmount,
saveAdminWorkerWithdrawalAccount,
@@ -149,7 +151,11 @@ export default function WorkersPanel() {
const [loadingWithdrawalAccounts, setLoadingWithdrawalAccounts] = useState(false)
const [savingWithdrawalAccount, setSavingWithdrawalAccount] = useState(false)
const [creditForm] = Form.useForm()
const [levelForm] = Form.useForm<{ levelId?: number }>()
const [levelForm] = Form.useForm<{
levelId?: number
isLevelLocked?: boolean
levelLockedReason?: string
}>()
const [withdrawalAccountForm] = Form.useForm<WithdrawalAccountFormValues>()
const [resetPasswordForm] = Form.useForm<{ password: string }>()
const [freezeForm] = Form.useForm<FreezeWorkerFormValues>()
@@ -388,13 +394,19 @@ export default function WorkersPanel() {
}
}
async function submitLevel(values: { levelId?: number }) {
async function submitLevel(values: {
levelId?: number
isLevelLocked?: boolean
levelLockedReason?: string
}) {
if (!levelWorker) return
try {
await reviewAdminWorkerUser(levelWorker.workerId, {
status: levelWorker.status,
levelId: Number(values.levelId || 0),
reviewNote: levelWorker.reviewNote,
isLevelLocked: Boolean(values.isLevelLocked),
levelLockedReason: values.levelLockedReason?.trim() || undefined,
})
message.success('打手等级已更新')
setLevelWorker(null)
@@ -405,6 +417,18 @@ export default function WorkersPanel() {
}
}
async function handleUnlockLevel(worker: WorkerUser) {
try {
await updateAdminWorkerLevelLock(worker.workerId, {
isLevelLocked: false,
})
message.success(`已解除打手 ${worker.displayName || worker.username} 的升级限制`)
await refreshWorkers()
} catch (error) {
message.error(error instanceof Error ? error.message : '解除升级限制失败')
}
}
async function submitPersonalDepositFreeAmount(values: PersonalDepositFreeAmountFormValues) {
if (!personalDepositFreeWorker) return
try {
@@ -605,7 +629,26 @@ export default function WorkersPanel() {
</div>
),
},
{ title: '等级', width: 140, render: (_, row) => row.level?.name || '-' },
{
title: '等级',
width: 150,
render: (_, row) => (
<Space size={4} wrap>
<span>{row.level?.name || '-'}</span>
{row.isLevelLocked ? (
<Tooltip
title={`已锁定自动升级${row.levelLockedReason ? `:${row.levelLockedReason}` : ''}${
row.levelLockedBy ? ` (操作人: ${row.levelLockedBy})` : ''
}`}
>
<Tag color="warning" icon={<LockOutlined />}>
已锁定
</Tag>
</Tooltip>
) : null}
</Space>
),
},
{
title: '免押额度',
width: 280,
@@ -738,11 +781,24 @@ export default function WorkersPanel() {
setLevelWorker(row)
levelForm.setFieldsValue({
levelId: row.level?.levelId,
isLevelLocked: row.isLevelLocked ?? true,
levelLockedReason: row.levelLockedReason || '',
})
}}
>
调等级
</Button>
{row.isLevelLocked ? (
<Popconfirm
title="解除等级升级限制"
description="解除后,该打手将重新允许根据累计完成单数自动晋升等级。确定解除限制吗?"
onConfirm={() => void handleUnlockLevel(row)}
>
<Button danger ghost>
解除限制
</Button>
</Popconfirm>
) : null}
<Button
onClick={() => {
setPersonalDepositFreeWorker(row)
@@ -1101,9 +1157,14 @@ export default function WorkersPanel() {
>
<Form form={levelForm} layout="vertical" onFinish={submitLevel}>
<Form.Item label="打手">
<Typography.Text>
<Typography.Text strong>
{levelWorker?.displayName || levelWorker?.username || '-'}
</Typography.Text>
{levelWorker?.level?.name ? (
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
(当前等级: {levelWorker.level.name})
</Typography.Text>
) : null}
</Form.Item>
<Form.Item
label="目标等级"
@@ -1118,6 +1179,22 @@ export default function WorkersPanel() {
}))}
/>
</Form.Item>
<Form.Item
label="锁定自动升级"
name="isLevelLocked"
valuePropName="checked"
extra="开启后该打手将无法再次触发按完成单数自动晋升逻辑;后续可随时解除限制。"
>
<Switch checkedChildren="锁定" unCheckedChildren="允许自动升级" />
</Form.Item>
<Form.Item label="调级/锁定原因" name="levelLockedReason">
<Input.TextArea
placeholder="请输入调级或锁定原因(如:人工降级惩罚、特殊定级等)"
rows={2}
maxLength={200}
showCount
/>
</Form.Item>
</Form>
</Modal>
@@ -418,6 +418,8 @@ export function reviewAdminWorkerUser(
freezeReason?: string
levelId?: number
workerType?: string
isLevelLocked?: boolean
levelLockedReason?: string
},
) {
return apiPost<{ worker: WorkerUser }>(
@@ -426,6 +428,19 @@ export function reviewAdminWorkerUser(
)
}
export function updateAdminWorkerLevelLock(
workerId: number,
payload: {
isLevelLocked: boolean
levelLockedReason?: string
},
) {
return apiPost<{ worker: WorkerUser }>(
`/api/v1/admin/worker-platform/workers/${workerId}/level-lock`,
payload,
)
}
export function saveAdminWorkerPersonalDepositFreeAmount(
workerId: number,
payload: { depositFreeAmount: number | null },
@@ -58,6 +58,14 @@ export type WorkerUser = {
canConsign: boolean
/** 开通寄卖权限时间 */
consignmentActivatedAt: string | null
/** 是否已锁定打手等级(禁止自动升级) */
isLevelLocked?: boolean
/** 等级锁定时间 */
levelLockedAt?: string | null
/** 等级锁定原因/调级备注 */
levelLockedReason?: string
/** 等级锁定操作人 */
levelLockedBy?: string
}
export type WorkerUserStatistics = {