feat(worker-platform): 增加寄卖白名单总开关,关闭后全员可发布
资金配置与打手列表可一键切换白名单模式;发布校验、打手端入口按总开关放行。同时收紧未开通判定、关权限确认和开通弹窗文案。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { canWorkerPublishConsignment } from './consignment-whitelist.js'
|
||||||
|
|
||||||
|
test('白名单关闭时所有打手都能发布寄卖', () => {
|
||||||
|
assert.equal(
|
||||||
|
canWorkerPublishConsignment({ whitelistEnabled: false, workerCanConsign: false }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
canWorkerPublishConsignment({ whitelistEnabled: false, workerCanConsign: true }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('白名单开启时仅开通权限的打手能发布', () => {
|
||||||
|
assert.equal(
|
||||||
|
canWorkerPublishConsignment({ whitelistEnabled: true, workerCanConsign: false }),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
canWorkerPublishConsignment({ whitelistEnabled: true, workerCanConsign: true }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 寄卖白名单总开关。
|
||||||
|
*
|
||||||
|
* 开启:只有 worker.can_consign 的打手能发布新寄卖。
|
||||||
|
* 关闭:所有打手都能发布;打手身上的白名单标记仍保留,方便再次开启。
|
||||||
|
*/
|
||||||
|
export function canWorkerPublishConsignment(input: {
|
||||||
|
whitelistEnabled: boolean
|
||||||
|
workerCanConsign: boolean
|
||||||
|
}): boolean {
|
||||||
|
return !input.whitelistEnabled || input.workerCanConsign
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { mapWorkerUser } from './worker-finance-mappers.js'
|
import { mapWorkerUser } from './worker-finance-mappers.js'
|
||||||
|
import { parseAdminWorkerConsignmentPermission } from './admin-worker-user-service.js'
|
||||||
import type { WorkerUserRow } from '../../repositories/worker-platform/types.js'
|
import type { WorkerUserRow } from '../../repositories/worker-platform/types.js'
|
||||||
|
|
||||||
test('mapWorkerUser 准确回显打手寄卖白名单权限与激活时间', () => {
|
test('mapWorkerUser 准确回显打手寄卖白名单权限与激活时间', () => {
|
||||||
@@ -48,3 +49,12 @@ test('管理员打手管理路由注册了寄卖白名单权限更新接口', as
|
|||||||
'路由必须调用 updateAdminWorkerConsignmentPermission',
|
'路由必须调用 updateAdminWorkerConsignmentPermission',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('更新寄卖权限必须显式传开关,空 body 不得默认关闭', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseAdminWorkerConsignmentPermission({}),
|
||||||
|
(error: unknown) => (error as { errorCode?: string }).errorCode === 'can_consign_required',
|
||||||
|
)
|
||||||
|
assert.equal(parseAdminWorkerConsignmentPermission({ canConsign: true }), true)
|
||||||
|
assert.equal(parseAdminWorkerConsignmentPermission({ enabled: false }), false)
|
||||||
|
})
|
||||||
|
|||||||
@@ -180,12 +180,23 @@ export async function updateAdminWorkerWithdrawalBlacklist(
|
|||||||
return { worker: mapWorkerUser(updated || worker) }
|
return { worker: mapWorkerUser(updated || worker) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAdminWorkerConsignmentPermission(payload: JsonObject = {}): boolean {
|
||||||
|
const raw = payload.canConsign ?? payload.enabled
|
||||||
|
if (raw === undefined || raw === null || raw === '') {
|
||||||
|
throw createHttpError('请明确指定寄卖权限开关', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'can_consign_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return normalizeBoolean(raw, false)
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateAdminWorkerConsignmentPermission(
|
export async function updateAdminWorkerConsignmentPermission(
|
||||||
workerId: number | string,
|
workerId: number | string,
|
||||||
payload: JsonObject = {},
|
payload: JsonObject = {},
|
||||||
) {
|
) {
|
||||||
const worker = await getRequiredWorker(workerId)
|
const worker = await getRequiredWorker(workerId)
|
||||||
const canConsign = normalizeBoolean(payload.canConsign ?? payload.enabled, false)
|
const canConsign = parseAdminWorkerConsignmentPermission(payload)
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const updated = await updateWorkerConsignmentPermission({
|
const updated = await updateWorkerConsignmentPermission({
|
||||||
workerId: worker.id,
|
workerId: worker.id,
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ test('寄卖提报硬校验:璀璨数不能超过钻石等级(1钻最多1璀
|
|||||||
test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截', async () => {
|
test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截', async () => {
|
||||||
const serviceSource = await readServiceSource()
|
const serviceSource = await readServiceSource()
|
||||||
assert.ok(
|
assert.ok(
|
||||||
serviceSource.includes('!worker.can_consign'),
|
serviceSource.includes('canWorkerPublishConsignment('),
|
||||||
'服务端必须拦截未开通寄卖权限的打手',
|
'服务端必须按白名单总开关决定是否拦截未开通打手',
|
||||||
)
|
)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
serviceSource.includes("errorCode: 'consignment_permission_required'"),
|
serviceSource.includes("errorCode: 'consignment_permission_required'"),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { listEnabledWorkProductRulesForSuggestion } from '../../repositories/wor
|
|||||||
import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-order-query-repo.js'
|
import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-order-query-repo.js'
|
||||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||||
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
|
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
|
||||||
|
import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js'
|
||||||
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
|
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
|
||||||
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
|
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
|
||||||
import type { JsonObject } from '../../types/json.js'
|
import type { JsonObject } from '../../types/json.js'
|
||||||
@@ -20,7 +21,10 @@ import { generateConsignmentSubmissionNo } from '../../utils/random.js'
|
|||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js'
|
import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js'
|
||||||
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
|
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||||
import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js'
|
import {
|
||||||
|
getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen,
|
||||||
|
getWorkerFinanceConfig,
|
||||||
|
} from './worker-finance-config-service.js'
|
||||||
import { confirmAdminWorkOrderFriendAdded } from './admin-work-order-gift-service.js'
|
import { confirmAdminWorkOrderFriendAdded } from './admin-work-order-gift-service.js'
|
||||||
import { submitWorkerOrderAcceptance } from './worker-acceptance-service.js'
|
import { submitWorkerOrderAcceptance } from './worker-acceptance-service.js'
|
||||||
import type { WorkerSession } from './worker-session-context-service.js'
|
import type { WorkerSession } from './worker-session-context-service.js'
|
||||||
@@ -144,8 +148,13 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
|||||||
if (!worker) {
|
if (!worker) {
|
||||||
throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' })
|
throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' })
|
||||||
}
|
}
|
||||||
if (!worker.can_consign) {
|
if (
|
||||||
throw createHttpError('您暂未开通寄卖权限,请联系客服开通', {
|
!canWorkerPublishConsignment({
|
||||||
|
whitelistEnabled: getWorkerFinanceConfig().consignmentWhitelistEnabled,
|
||||||
|
workerCanConsign: Boolean(worker.can_consign),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw createHttpError('您暂未开通寄卖权限,请联系客服申请开通', {
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
errorCode: 'consignment_permission_required',
|
errorCode: 'consignment_permission_required',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export type WorkerFinanceConfig = {
|
|||||||
rewardUnfreezeEnabled: boolean
|
rewardUnfreezeEnabled: boolean
|
||||||
/** 苏尔南寄卖发布所需履约押金(分),默认 2000 = ¥20 */
|
/** 苏尔南寄卖发布所需履约押金(分),默认 2000 = ¥20 */
|
||||||
consignmentPublishDepositFen: number
|
consignmentPublishDepositFen: number
|
||||||
|
/**
|
||||||
|
* 寄卖白名单总开关。开启后仅 can_consign 打手可发布;关闭后所有打手可发布。
|
||||||
|
* 缺省视为开启,保持当前白名单行为。
|
||||||
|
*/
|
||||||
|
consignmentWhitelistEnabled: boolean
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
channelName: string
|
channelName: string
|
||||||
@@ -71,6 +76,9 @@ export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceCo
|
|||||||
0,
|
0,
|
||||||
1_000_000,
|
1_000_000,
|
||||||
),
|
),
|
||||||
|
consignmentWhitelistEnabled:
|
||||||
|
source.consignmentWhitelistEnabled !== false &&
|
||||||
|
source.consignment_whitelist_enabled !== false,
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true,
|
enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true,
|
||||||
channelName: String(recharge.channelName || recharge.channel_name || '').trim(),
|
channelName: String(recharge.channelName || recharge.channel_name || '').trim(),
|
||||||
@@ -97,6 +105,7 @@ export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig {
|
|||||||
depositUnfreezeDays: 3,
|
depositUnfreezeDays: 3,
|
||||||
rewardUnfreezeEnabled: true,
|
rewardUnfreezeEnabled: true,
|
||||||
consignmentPublishDepositFen: 2000,
|
consignmentPublishDepositFen: 2000,
|
||||||
|
consignmentWhitelistEnabled: true,
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
channelName: '',
|
channelName: '',
|
||||||
@@ -120,6 +129,7 @@ function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceC
|
|||||||
depositUnfreezeDays: config.depositUnfreezeDays,
|
depositUnfreezeDays: config.depositUnfreezeDays,
|
||||||
rewardUnfreezeEnabled: config.rewardUnfreezeEnabled,
|
rewardUnfreezeEnabled: config.rewardUnfreezeEnabled,
|
||||||
consignmentPublishDepositFen: config.consignmentPublishDepositFen,
|
consignmentPublishDepositFen: config.consignmentPublishDepositFen,
|
||||||
|
consignmentWhitelistEnabled: config.consignmentWhitelistEnabled,
|
||||||
recharge: {
|
recharge: {
|
||||||
...config.recharge,
|
...config.recharge,
|
||||||
qrCodeImage: config.recharge.qrCodeImage
|
qrCodeImage: config.recharge.qrCodeImage
|
||||||
|
|||||||
@@ -402,6 +402,19 @@ test('寄卖发布押金配置保留显式零值并回退非法值', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('寄卖白名单总开关缺省开启,显式 false 才全员开放', () => {
|
||||||
|
assert.equal(normalizeWorkerFinanceConfig({}).consignmentWhitelistEnabled, true)
|
||||||
|
assert.equal(
|
||||||
|
normalizeWorkerFinanceConfig({ consignmentWhitelistEnabled: false })
|
||||||
|
.consignmentWhitelistEnabled,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
normalizeWorkerFinanceConfig({ consignmentWhitelistEnabled: true }).consignmentWhitelistEnabled,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('寄卖押金释放先锁钱包再读流水,冻结额不足时拒绝部分退款', async () => {
|
test('寄卖押金释放先锁钱包再读流水,冻结额不足时拒绝部分退款', async () => {
|
||||||
const statements: string[] = []
|
const statements: string[] = []
|
||||||
const client = {
|
const client = {
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ export type WorkerContactModalProps = {
|
|||||||
worker?: WorkerUser | null
|
worker?: WorkerUser | null
|
||||||
financeConfig?: WorkerFinanceConfig | null
|
financeConfig?: WorkerFinanceConfig | null
|
||||||
title?: string
|
title?: string
|
||||||
|
alertMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ALERT_MESSAGE =
|
||||||
|
'如需取消已接订单,请扫码联系客服处理。充值、提现与审核问题也由后台人工客服处理。'
|
||||||
|
|
||||||
export function WorkerContactModal({
|
export function WorkerContactModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -19,6 +23,7 @@ export function WorkerContactModal({
|
|||||||
worker,
|
worker,
|
||||||
financeConfig,
|
financeConfig,
|
||||||
title = '联系客服',
|
title = '联系客服',
|
||||||
|
alertMessage = DEFAULT_ALERT_MESSAGE,
|
||||||
}: WorkerContactModalProps) {
|
}: WorkerContactModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -34,11 +39,7 @@ export function WorkerContactModal({
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={16} className="full-width">
|
<Space direction="vertical" size={16} className="full-width">
|
||||||
<Alert
|
<Alert showIcon type="info" message={alertMessage} />
|
||||||
showIcon
|
|
||||||
type="info"
|
|
||||||
message="如需取消已接订单,请扫码联系客服处理。充值、提现与审核问题也由后台人工客服处理。"
|
|
||||||
/>
|
|
||||||
{financeConfig?.adminContact?.wechatQrCodeImage ? (
|
{financeConfig?.adminContact?.wechatQrCodeImage ? (
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type FinanceConfigFormValues = {
|
|||||||
depositUnfreezeDays?: number
|
depositUnfreezeDays?: number
|
||||||
rewardUnfreezeEnabled?: boolean
|
rewardUnfreezeEnabled?: boolean
|
||||||
consignmentPublishDepositYuan?: number
|
consignmentPublishDepositYuan?: number
|
||||||
|
consignmentWhitelistEnabled?: boolean
|
||||||
recharge?: {
|
recharge?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
channelName?: string
|
channelName?: string
|
||||||
@@ -173,6 +174,7 @@ export default function FinancePanel() {
|
|||||||
consignmentPublishDepositFen: Math.round(
|
consignmentPublishDepositFen: Math.round(
|
||||||
Number(values.consignmentPublishDepositYuan ?? 20) * 100,
|
Number(values.consignmentPublishDepositYuan ?? 20) * 100,
|
||||||
),
|
),
|
||||||
|
consignmentWhitelistEnabled: values.consignmentWhitelistEnabled !== false,
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: values.recharge?.enabled !== false,
|
enabled: values.recharge?.enabled !== false,
|
||||||
channelName: String(values.recharge?.channelName || '').trim(),
|
channelName: String(values.recharge?.channelName || '').trim(),
|
||||||
@@ -426,6 +428,14 @@ export default function FinancePanel() {
|
|||||||
>
|
>
|
||||||
<InputNumber min={0} max={10000} step={1} addonBefore="¥" />
|
<InputNumber min={0} max={10000} step={1} addonBefore="¥" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="寄卖白名单"
|
||||||
|
name="consignmentWhitelistEnabled"
|
||||||
|
valuePropName="checked"
|
||||||
|
tooltip="开启后只有后台开通权限的打手能发布寄卖;关闭后所有打手均可发布。打手身上的白名单标记会保留,方便再次开启。"
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="仅白名单" unCheckedChildren="全员可寄卖" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="报酬待解冻"
|
label="报酬待解冻"
|
||||||
name="rewardUnfreezeEnabled"
|
name="rewardUnfreezeEnabled"
|
||||||
@@ -687,6 +697,7 @@ function mapFinanceConfigToFormValues(config?: WorkerFinanceConfig): FinanceConf
|
|||||||
depositUnfreezeDays: Number(config?.depositUnfreezeDays ?? 3),
|
depositUnfreezeDays: Number(config?.depositUnfreezeDays ?? 3),
|
||||||
rewardUnfreezeEnabled: config?.rewardUnfreezeEnabled !== false,
|
rewardUnfreezeEnabled: config?.rewardUnfreezeEnabled !== false,
|
||||||
consignmentPublishDepositYuan: Number(config?.consignmentPublishDepositFen ?? 2000) / 100,
|
consignmentPublishDepositYuan: Number(config?.consignmentPublishDepositFen ?? 2000) / 100,
|
||||||
|
consignmentWhitelistEnabled: config?.consignmentWhitelistEnabled !== false,
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: config?.recharge.enabled !== false,
|
enabled: config?.recharge.enabled !== false,
|
||||||
channelName: config?.recharge.channelName || '',
|
channelName: config?.recharge.channelName || '',
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ function OverviewSection({ workerId }: { workerId: number }) {
|
|||||||
<Descriptions.Item label="寄卖权限" span={2}>
|
<Descriptions.Item label="寄卖权限" span={2}>
|
||||||
<Space>
|
<Space>
|
||||||
<Tag color={worker.canConsign ? 'green' : 'default'}>
|
<Tag color={worker.canConsign ? 'green' : 'default'}>
|
||||||
{worker.canConsign ? '已开通寄卖' : '未开通(白名单限制)'}
|
{worker.canConsign ? '已加入白名单' : '未加入白名单'}
|
||||||
</Tag>
|
</Tag>
|
||||||
{worker.consignmentActivatedAt ? (
|
{worker.consignmentActivatedAt ? (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ import { useSearchParams } from 'react-router'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
creditAdminWorkerWallet,
|
creditAdminWorkerWallet,
|
||||||
|
fetchAdminWorkerFinanceConfig,
|
||||||
fetchAdminWorkerLevels,
|
fetchAdminWorkerLevels,
|
||||||
fetchAdminWorkerUsers,
|
fetchAdminWorkerUsers,
|
||||||
fetchAdminWorkerWithdrawalAccounts,
|
fetchAdminWorkerWithdrawalAccounts,
|
||||||
resetAdminWorkerPassword,
|
resetAdminWorkerPassword,
|
||||||
reviewAdminWorkerUser,
|
reviewAdminWorkerUser,
|
||||||
|
saveAdminWorkerFinanceConfig,
|
||||||
saveAdminWorkerPersonalDepositFreeAmount,
|
saveAdminWorkerPersonalDepositFreeAmount,
|
||||||
saveAdminWorkerWithdrawalAccount,
|
saveAdminWorkerWithdrawalAccount,
|
||||||
updateAdminWorkerWithdrawalBlacklist,
|
updateAdminWorkerWithdrawalBlacklist,
|
||||||
@@ -205,6 +207,13 @@ export default function WorkersPanel() {
|
|||||||
queryFn: () => fetchAdminWorkerLevels(),
|
queryFn: () => fetchAdminWorkerLevels(),
|
||||||
})
|
})
|
||||||
const workersPagination = workersQuery.data?.data.pagination
|
const workersPagination = workersQuery.data?.data.pagination
|
||||||
|
const financeConfigQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-finance-config'],
|
||||||
|
queryFn: () => fetchAdminWorkerFinanceConfig(),
|
||||||
|
})
|
||||||
|
const consignmentWhitelistEnabled =
|
||||||
|
financeConfigQuery.data?.data.consignmentWhitelistEnabled !== false
|
||||||
|
const [updatingWhitelist, setUpdatingWhitelist] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 从工单流转记录等入口带 ?workerId= 定位到打手后,自动打开一次详情,关闭后不再重复弹。
|
// 从工单流转记录等入口带 ?workerId= 定位到打手后,自动打开一次详情,关闭后不再重复弹。
|
||||||
@@ -225,6 +234,9 @@ export default function WorkersPanel() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['admin-worker-platform-summary'],
|
queryKey: ['admin-worker-platform-summary'],
|
||||||
}),
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-finance-config'],
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,47 +478,117 @@ export default function WorkersPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updatingConsignmentWorkerId, setUpdatingConsignmentWorkerId] = useState<number | null>(
|
const [updatingConsignmentWorkerIds, setUpdatingConsignmentWorkerIds] = useState<Set<number>>(
|
||||||
null,
|
() => new Set(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function markConsignmentWorkerUpdating(workerId: number, updating: boolean) {
|
||||||
|
setUpdatingConsignmentWorkerIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (updating) next.add(workerId)
|
||||||
|
else next.delete(workerId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUpdatedWorker(row: WorkerUser, updatedWorker: WorkerUser) {
|
||||||
|
queryClient.setQueriesData<{ data: { items: WorkerUser[]; pagination: any } }>(
|
||||||
|
{ queryKey: ['admin-worker-platform-workers'] },
|
||||||
|
(old) => {
|
||||||
|
if (!old?.data?.items) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
data: {
|
||||||
|
...old.data,
|
||||||
|
items: old.data.items.map((item) =>
|
||||||
|
item.workerId === row.workerId ? { ...item, ...updatedWorker } : item,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
setDetailWorker((current) =>
|
||||||
|
current?.workerId === row.workerId ? { ...current, ...updatedWorker } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToggleConsignmentPermission(row: WorkerUser, checked: boolean) {
|
async function handleToggleConsignmentPermission(row: WorkerUser, checked: boolean) {
|
||||||
setUpdatingConsignmentWorkerId(row.workerId)
|
markConsignmentWorkerUpdating(row.workerId, true)
|
||||||
try {
|
try {
|
||||||
const res = await updateAdminWorkerConsignmentPermission(row.workerId, {
|
const res = await updateAdminWorkerConsignmentPermission(row.workerId, {
|
||||||
canConsign: checked,
|
canConsign: checked,
|
||||||
})
|
})
|
||||||
const updatedWorker = res.data?.worker
|
const updatedWorker = res.data?.worker
|
||||||
if (updatedWorker) {
|
if (updatedWorker) {
|
||||||
queryClient.setQueriesData<{ data: { items: WorkerUser[]; pagination: any } }>(
|
applyUpdatedWorker(row, updatedWorker)
|
||||||
{ queryKey: ['admin-worker-platform-workers'] },
|
|
||||||
(old) => {
|
|
||||||
if (!old?.data?.items) return old
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
data: {
|
|
||||||
...old.data,
|
|
||||||
items: old.data.items.map((item) =>
|
|
||||||
item.workerId === row.workerId ? { ...item, ...updatedWorker } : item,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
message.success(
|
message.success(
|
||||||
checked
|
checked
|
||||||
? `已为打手 ${row.displayName || row.username} 开通寄卖权限`
|
? `已为打手 ${row.displayName || row.username} 加入寄卖白名单`
|
||||||
: `已关闭打手 ${row.displayName || row.username} 的寄卖权限`,
|
: `已将打手 ${row.displayName || row.username} 移出寄卖白名单`,
|
||||||
)
|
)
|
||||||
await refreshWorkers()
|
await refreshWorkers()
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
message.error(err instanceof Error ? err.message : '更新寄卖权限失败')
|
message.error(err instanceof Error ? err.message : '更新寄卖权限失败')
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingConsignmentWorkerId(null)
|
markConsignmentWorkerUpdating(row.workerId, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestToggleConsignmentPermission(row: WorkerUser, checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
void handleToggleConsignmentPermission(row, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal.confirm({
|
||||||
|
title: '移出寄卖白名单?',
|
||||||
|
content: `确认关闭打手“${row.displayName || row.username}”的寄卖权限?白名单开启时,该打手将无法发布新寄卖;已在途的寄卖不受影响。`,
|
||||||
|
okText: '确认关闭',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => handleToggleConsignmentPermission(row, false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleConsignmentWhitelist(checked: boolean) {
|
||||||
|
const current = financeConfigQuery.data?.data
|
||||||
|
if (!current) {
|
||||||
|
message.error('资金配置尚未加载完成,请稍后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal.confirm({
|
||||||
|
title: checked ? '开启寄卖白名单?' : '关闭寄卖白名单?',
|
||||||
|
content: checked
|
||||||
|
? '开启后只有白名单中的打手可以发布寄卖,未开通的打手会被拦截。'
|
||||||
|
: '关闭后所有打手都可以发布寄卖。打手身上的白名单标记会保留,方便再次开启。',
|
||||||
|
okText: checked ? '确认开启' : '确认关闭',
|
||||||
|
okButtonProps: checked ? undefined : { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setUpdatingWhitelist(true)
|
||||||
|
try {
|
||||||
|
await saveAdminWorkerFinanceConfig({
|
||||||
|
depositUnfreezeDays: current.depositUnfreezeDays,
|
||||||
|
rewardUnfreezeEnabled: current.rewardUnfreezeEnabled,
|
||||||
|
consignmentPublishDepositFen: current.consignmentPublishDepositFen,
|
||||||
|
consignmentWhitelistEnabled: checked,
|
||||||
|
recharge: current.recharge,
|
||||||
|
adminContact: current.adminContact,
|
||||||
|
withdraw: current.withdraw,
|
||||||
|
})
|
||||||
|
message.success(checked ? '已开启寄卖白名单' : '已关闭寄卖白名单,全部打手可发布')
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-finance-config'],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '更新寄卖白名单失败')
|
||||||
|
} finally {
|
||||||
|
setUpdatingWhitelist(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const columns: TableColumnsType<WorkerUser> = [
|
const columns: TableColumnsType<WorkerUser> = [
|
||||||
{
|
{
|
||||||
title: '打手',
|
title: '打手',
|
||||||
@@ -598,16 +680,24 @@ export default function WorkersPanel() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '寄卖权限',
|
title: '寄卖白名单',
|
||||||
width: 120,
|
width: 130,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Switch
|
<Tooltip
|
||||||
checkedChildren="已开通"
|
title={
|
||||||
unCheckedChildren="未开通"
|
consignmentWhitelistEnabled
|
||||||
checked={row.canConsign}
|
? '白名单开启中:仅已开通的打手可发布寄卖'
|
||||||
loading={updatingConsignmentWorkerId === row.workerId}
|
: '白名单已关闭:开关暂不限制发布,再次开启后才会生效'
|
||||||
onChange={(checked) => handleToggleConsignmentPermission(row, checked)}
|
}
|
||||||
/>
|
>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="已开通"
|
||||||
|
unCheckedChildren="未开通"
|
||||||
|
checked={row.canConsign}
|
||||||
|
loading={updatingConsignmentWorkerIds.has(row.workerId)}
|
||||||
|
onChange={(checked) => requestToggleConsignmentPermission(row, checked)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -778,7 +868,33 @@ export default function WorkersPanel() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
<WorkerStatisticsBar statistics={workersQuery.data?.data.statistics} />
|
<WorkerStatisticsBar statistics={workersQuery.data?.data.statistics} />
|
||||||
<Card title="打手列表" bordered={false}>
|
<Card
|
||||||
|
title="打手列表"
|
||||||
|
bordered={false}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Typography.Text type="secondary">寄卖白名单</Typography.Text>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="仅白名单"
|
||||||
|
unCheckedChildren="全员可寄卖"
|
||||||
|
checked={consignmentWhitelistEnabled}
|
||||||
|
loading={updatingWhitelist || financeConfigQuery.isLoading}
|
||||||
|
disabled={!financeConfigQuery.data?.data}
|
||||||
|
onChange={(checked) => void handleToggleConsignmentWhitelist(checked)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type={consignmentWhitelistEnabled ? 'warning' : 'info'}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message={
|
||||||
|
consignmentWhitelistEnabled
|
||||||
|
? '寄卖白名单已开启:仅下方开关为「已开通」的打手可发布寄卖。客服无法开通,需运营或管理员操作。'
|
||||||
|
: '寄卖白名单已关闭:全部打手均可发布寄卖。下方开关仅在重新开启白名单后生效。'
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Table<WorkerUser>
|
<Table<WorkerUser>
|
||||||
rowKey="workerId"
|
rowKey="workerId"
|
||||||
loading={workersQuery.isLoading}
|
loading={workersQuery.isLoading}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
|
Spin,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
SURNAN_PUBLISH_ZONE_OPTIONS,
|
SURNAN_PUBLISH_ZONE_OPTIONS,
|
||||||
} from '@/utils/consignment-spec'
|
} from '@/utils/consignment-spec'
|
||||||
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
|
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
|
||||||
|
import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||||
|
|
||||||
@@ -200,13 +202,13 @@ export default function WorkerConsignmentPage() {
|
|||||||
const publishDepositYuan = publishDepositFen / 100
|
const publishDepositYuan = publishDepositFen / 100
|
||||||
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
|
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
|
||||||
const canPublish = availableAmount >= publishDepositFen
|
const canPublish = availableAmount >= publishDepositFen
|
||||||
const canConsign = worker?.canConsign !== false
|
const canConsign = canWorkerPublishConsignment({
|
||||||
|
profileReady: !profileQuery.isLoading && Boolean(worker),
|
||||||
|
whitelistEnabled: financeConfig?.consignmentWhitelistEnabled !== false,
|
||||||
|
workerCanConsign: worker?.canConsign === true,
|
||||||
|
})
|
||||||
const [contactOpen, setContactOpen] = useState(false)
|
const [contactOpen, setContactOpen] = useState(false)
|
||||||
|
|
||||||
const reviewingCount = records.filter((item) => item.group === 'reviewing').length
|
|
||||||
const activeCount = records.filter((item) => item.group === 'active').length
|
|
||||||
const finishedCount = records.filter((item) => item.group === 'finished').length
|
|
||||||
|
|
||||||
// 阶段 → 分组由后端下发(item.group),这里只把它映射到筛选角标计数上
|
// 阶段 → 分组由后端下发(item.group),这里只把它映射到筛选角标计数上
|
||||||
const groupCounts = useMemo(() => {
|
const groupCounts = useMemo(() => {
|
||||||
const stageGroups = new Map<string, string>()
|
const stageGroups = new Map<string, string>()
|
||||||
@@ -527,7 +529,7 @@ export default function WorkerConsignmentPage() {
|
|||||||
<div className="worker-consignment-kpi-main">
|
<div className="worker-consignment-kpi-main">
|
||||||
<span className="worker-consignment-kpi-title">待审核</span>
|
<span className="worker-consignment-kpi-title">待审核</span>
|
||||||
<span className="worker-consignment-kpi-val">
|
<span className="worker-consignment-kpi-val">
|
||||||
{reviewingCount}
|
{groupCounts.reviewing}
|
||||||
<span className="worker-consignment-kpi-unit">单</span>
|
<span className="worker-consignment-kpi-unit">单</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -546,8 +548,8 @@ export default function WorkerConsignmentPage() {
|
|||||||
<div className="worker-consignment-kpi-main">
|
<div className="worker-consignment-kpi-main">
|
||||||
<span className="worker-consignment-kpi-title">履约中</span>
|
<span className="worker-consignment-kpi-title">履约中</span>
|
||||||
<span className="worker-consignment-kpi-val">
|
<span className="worker-consignment-kpi-val">
|
||||||
{activeCount}
|
{groupCounts.active}
|
||||||
<span className="worker-consignment-kpi-unit">把</span>
|
<span className="worker-consignment-kpi-unit">单</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="worker-consignment-kpi-icon in-progress">
|
<div className="worker-consignment-kpi-icon in-progress">
|
||||||
@@ -565,8 +567,8 @@ export default function WorkerConsignmentPage() {
|
|||||||
<div className="worker-consignment-kpi-main">
|
<div className="worker-consignment-kpi-main">
|
||||||
<span className="worker-consignment-kpi-title">已完成</span>
|
<span className="worker-consignment-kpi-title">已完成</span>
|
||||||
<span className="worker-consignment-kpi-val">
|
<span className="worker-consignment-kpi-val">
|
||||||
{finishedCount}
|
{groupCounts.finished}
|
||||||
<span className="worker-consignment-kpi-unit">把</span>
|
<span className="worker-consignment-kpi-unit">单</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="worker-consignment-kpi-icon accepted">
|
<div className="worker-consignment-kpi-icon accepted">
|
||||||
@@ -588,7 +590,11 @@ export default function WorkerConsignmentPage() {
|
|||||||
<DollarCircleOutlined /> 发布寄卖
|
<DollarCircleOutlined /> 发布寄卖
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
children: !canConsign ? (
|
children: profileQuery.isLoading ? (
|
||||||
|
<div style={{ padding: '48px 16px', textAlign: 'center' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : !canConsign ? (
|
||||||
<div style={{ padding: '36px 16px', textAlign: 'center' }}>
|
<div style={{ padding: '36px 16px', textAlign: 'center' }}>
|
||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
@@ -601,7 +607,7 @@ export default function WorkerConsignmentPage() {
|
|||||||
type="secondary"
|
type="secondary"
|
||||||
style={{ fontSize: 13, marginBottom: 20 }}
|
style={{ fontSize: 13, marginBottom: 20 }}
|
||||||
>
|
>
|
||||||
为保障寄卖道具交付质量与履约安全,平台当前实行白名单审核制。请联系在线客服申请开通寄卖权限。
|
平台当前开启寄卖白名单。请联系客服申请开通,由后台运营开通后即可发布苏尔南寄卖。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -972,6 +978,7 @@ export default function WorkerConsignmentPage() {
|
|||||||
worker={worker}
|
worker={worker}
|
||||||
financeConfig={financeConfig}
|
financeConfig={financeConfig}
|
||||||
title="联系客服开通寄卖"
|
title="联系客服开通寄卖"
|
||||||
|
alertMessage="请扫码联系客服申请开通寄卖。开通由后台运营处理,通过后即可发布苏尔南。"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export function saveAdminWorkerFinanceConfig(payload: {
|
|||||||
depositUnfreezeDays?: number
|
depositUnfreezeDays?: number
|
||||||
rewardUnfreezeEnabled?: boolean
|
rewardUnfreezeEnabled?: boolean
|
||||||
consignmentPublishDepositFen?: number
|
consignmentPublishDepositFen?: number
|
||||||
|
consignmentWhitelistEnabled?: boolean
|
||||||
recharge?: {
|
recharge?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
channelName?: string
|
channelName?: string
|
||||||
|
|||||||
@@ -265,6 +265,8 @@ export type WorkerFinanceConfig = {
|
|||||||
rewardUnfreezeEnabled: boolean
|
rewardUnfreezeEnabled: boolean
|
||||||
/** 苏尔南寄卖发布履约押金(分),默认 2000 */
|
/** 苏尔南寄卖发布履约押金(分),默认 2000 */
|
||||||
consignmentPublishDepositFen?: number
|
consignmentPublishDepositFen?: number
|
||||||
|
/** 寄卖白名单总开关。开启后仅白名单打手可发布;关闭后所有打手可发布。缺省视为开启。 */
|
||||||
|
consignmentWhitelistEnabled?: boolean
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
channelName: string
|
channelName: string
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 打手端是否允许进入「发布寄卖」表单。
|
||||||
|
* 资料未就绪时一律不放行,避免白名单模式下先闪出表单。
|
||||||
|
*/
|
||||||
|
export function canWorkerPublishConsignment(input: {
|
||||||
|
profileReady: boolean
|
||||||
|
whitelistEnabled: boolean
|
||||||
|
workerCanConsign: boolean
|
||||||
|
}): boolean {
|
||||||
|
if (!input.profileReady) return false
|
||||||
|
return !input.whitelistEnabled || input.workerCanConsign
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { canWorkerPublishConsignment } from '../src/utils/consignment-whitelist.ts'
|
||||||
|
|
||||||
|
test('资料未就绪时不开放发布入口', () => {
|
||||||
|
assert.equal(
|
||||||
|
canWorkerPublishConsignment({
|
||||||
|
profileReady: false,
|
||||||
|
whitelistEnabled: false,
|
||||||
|
workerCanConsign: true,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('白名单关闭且资料就绪时所有打手可发布', () => {
|
||||||
|
assert.equal(
|
||||||
|
canWorkerPublishConsignment({
|
||||||
|
profileReady: true,
|
||||||
|
whitelistEnabled: false,
|
||||||
|
workerCanConsign: false,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('白名单开启时必须 workerCanConsign === true', () => {
|
||||||
|
assert.equal(
|
||||||
|
canWorkerPublishConsignment({
|
||||||
|
profileReady: true,
|
||||||
|
whitelistEnabled: true,
|
||||||
|
workerCanConsign: false,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
canWorkerPublishConsignment({
|
||||||
|
profileReady: true,
|
||||||
|
whitelistEnabled: true,
|
||||||
|
workerCanConsign: true,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user