diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index ef942f11..786892ba 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -98,12 +98,8 @@ import { resolveWorkerPermissions, resolveWorkOrderOperationalStatus, } from './mappers.js' -import { - ensureWorkerPlatformDefaults, - getRequiredWorkOrder, - getRequiredWorker, - normalizeWorkOrderTimeoutPolicy, -} from './worker-service.js' +import { ensureWorkerPlatformDefaults, normalizeWorkOrderTimeoutPolicy } from './worker-service.js' +import { getRequiredWorkOrder, getRequiredWorker } from './worker-session-context-service.js' import { resolveSharingQuantity } from './admin-worker-catalog-service.js' import { getKuaishouIndustrySourceConfig, diff --git a/apps/backend/src/services/worker-platform/admin-worker-finance-service.ts b/apps/backend/src/services/worker-platform/admin-worker-finance-service.ts index 2115028b..0a4ea545 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-finance-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-finance-service.ts @@ -29,7 +29,7 @@ import { normalizeProofFiles, normalizeWithdrawChannel, } from './mappers.js' -import { getRequiredWorker } from './worker-service.js' +import { getRequiredWorker } from './worker-session-context-service.js' import { getWorkerFinanceConfig, saveWorkerFinanceConfig } from './worker-finance-config-service.js' function mapAdminWorkerWithdrawalAccount(account: WorkerWithdrawalAccountRow) { diff --git a/apps/backend/src/services/worker-platform/after-sales-service.ts b/apps/backend/src/services/worker-platform/after-sales-service.ts index 442c5f50..db590a6f 100644 --- a/apps/backend/src/services/worker-platform/after-sales-service.ts +++ b/apps/backend/src/services/worker-platform/after-sales-service.ts @@ -25,7 +25,7 @@ import { normalizeProofFiles, normalizeUploadedFiles, } from './mappers.js' -import { requireActiveWorkerSession, type WorkerSession } from './worker-service.js' +import { requireActiveWorkerSession, type WorkerSession } from './worker-session-context-service.js' export async function listAdminAfterSalesCases(query: JsonObject = {}) { const page = normalizePage(query.page) diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 720a00e8..3e572378 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -1,4 +1,5 @@ export * from './mappers.js' +export * from './worker-session-context-service.js' export * from './worker-service.js' export * from './after-sales-service.js' export * from './admin-service.js' diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 1060a56a..97c2e0a8 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -1,6 +1,3 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { addWorkerWalletCredit, @@ -21,13 +18,45 @@ import { import type { JsonObject } from '../../types/json.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { createHttpError } from '../../utils/http.js' -import { nowIso, addHours } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { normalizeStoredFileUrl, refreshStoredFileUrl, refreshUploadedFileUrls, } from '../file-storage/file-storage-service.js' +import { createWorkerSessionToken } from './worker-auth-utils.js' +import { + normalizeBoolean, + normalizeInteger, + normalizePositiveInteger, + normalizeSessionVersion, +} from './worker-normalizers.js' +import { + canWorkerAutoAcceptWholeOrder, + resolveFreezeDepositAmount, + resolveVisibleDelaySeconds, + resolveWorkerPermissions, +} from './worker-permission-utils.js' + +export { + ensureWorkerAuthConfigured, + hashWorkerPassword, + safeCompare, + signWorkerPayload, + verifyWorkerPassword, +} from './worker-auth-utils.js' +export { + normalizeBoolean, + normalizeInteger, + normalizePositiveInteger, + normalizeSessionVersion, +} from './worker-normalizers.js' +export { + canWorkerAutoAcceptWholeOrder, + resolveFreezeDepositAmount, + resolveVisibleDelaySeconds, + resolveWorkerPermissions, +} from './worker-permission-utils.js' export const DEFAULT_LEVEL_KEY = 'vip1' export const DEFAULT_LEVEL_NAME = 'VIP1' @@ -38,115 +67,13 @@ export const INVITE_CODE_RETRY_LIMIT = 5 export const WORKER_CANCEL_LIMIT_PER_WINDOW = 5 export const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000 export function createWorkerSession(worker: WorkerUserRow) { - const issuedAt = nowIso() - const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12)) - const payload = { - typ: 'worker', - sid: crypto.randomBytes(12).toString('hex'), - uid: Number(worker.id), - usr: worker.username, - status: worker.status, - ver: normalizeSessionVersion(worker.session_version), - iat: issuedAt, - exp: expiresAt, - } - const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') + const session = createWorkerSessionToken(worker) return { - sessionId: payload.sid, - token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`, - expiresAt, + ...session, worker: mapWorkerUser(worker), } } -export function ensureWorkerAuthConfigured() { - if (String(runtimeConfig.admin?.sessionSecret || '').trim()) { - return - } - - throw createHttpError('接单登录态签名密钥未配置,请设置 ADMIN_SESSION_SECRET', { - statusCode: 503, - errorCode: 'worker_auth_not_configured', - }) -} - -export function hashWorkerPassword(password: string): string { - const salt = crypto.randomBytes(16).toString('hex') - const derived = crypto.scryptSync(password, salt, 64).toString('hex') - return `scrypt$${salt}$${derived}` -} - -export function verifyWorkerPassword(password: string, storedHash: string): boolean { - const [algorithm, salt, expectedHash] = String(storedHash || '').split('$') - if (algorithm !== 'scrypt' || !salt || !expectedHash) return false - return safeCompare(crypto.scryptSync(password, salt, 64).toString('hex'), expectedHash) -} - -export function signWorkerPayload(encodedPayload: string): string { - return crypto - .createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || '')) - .update(`worker:${encodedPayload}`) - .digest('base64url') -} - -export function safeCompare(input: unknown, expected: unknown): boolean { - const left = Buffer.from(String(input || ''), 'utf8') - const right = Buffer.from(String(expected || ''), 'utf8') - if (left.length !== right.length) return false - return crypto.timingSafeEqual(left, right) -} - -export function resolveWorkerPermissions(worker: WorkerUserRow) { - const permission = safeParseJson(worker.level_permission_json) - return { - depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), - maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1), - upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0), - autoAcceptWithoutEvidence: normalizeBoolean(permission.autoAcceptWithoutEvidence, false), - visibleDelaySeconds: resolveVisibleDelaySeconds( - worker.level_key || '', - permission.visibleDelaySeconds, - ), - } -} - -/** - * 整单接单人与拼单参与者使用不同验收路径。 - * 母工单保留拼单配置时,后台仍可在无人参与前整单指派,不能因此失去 VIP 免审核资格。 - */ -export function canWorkerAutoAcceptWholeOrder(input: { - worker: WorkerUserRow - hasPersonalSharingShare: boolean -}): boolean { - return ( - !input.hasPersonalSharingShare && - resolveWorkerPermissions(input.worker).autoAcceptWithoutEvidence - ) -} - -export function resolveVisibleDelaySeconds(levelKey: string, configured?: unknown): number { - const configuredValue = normalizeInteger(configured, -1) - if (configuredValue >= 0) return configuredValue - const defaultDelays: Record = { - vip1: 20, - vip2: 15, - vip3: 10, - vip4: 5, - vip5: 0, - } - return defaultDelays[String(levelKey || '').toLowerCase()] ?? 0 -} - -export function resolveFreezeDepositAmount( - workOrder: WorkOrderRow, - permissions: { depositFreeAmount: number }, -) { - return Math.max( - 0, - Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0), - ) -} - export function mapWorkerLevel(level: WorkerLevelRow | null | undefined) { if (!level) return null const permission = safeParseJson(level.permission_json) @@ -1637,17 +1564,6 @@ export function normalizeMatchType(value: unknown) { return 'contains' } -export function normalizeBoolean(value: unknown, fallback: boolean) { - if (typeof value === 'boolean') return value - const text = String(value ?? '') - .trim() - .toLowerCase() - if (!text) return fallback - if (['true', '1', 'yes', 'enabled', 'active', 'on'].includes(text)) return true - if (['false', '0', 'no', 'disabled', 'off'].includes(text)) return false - return fallback -} - export function normalizeSlugKey(value: unknown, fallback: string) { const key = String(value || '') .trim() @@ -1689,26 +1605,11 @@ export function isWorkerPasswordStrong(password: string): boolean { return password.length >= 8 && /[a-zA-Z]/.test(password) && /\d/.test(password) } -export function normalizeSessionVersion(value: unknown): number { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 -} - export function normalizeOptionalId(value: unknown): number | null { const parsed = Number(value) return Number.isInteger(parsed) && parsed > 0 ? parsed : null } -export function normalizeInteger(value: unknown, fallback: number): number { - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : fallback -} - -export function normalizePositiveInteger(value: unknown, fallback: number): number { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback -} - export function normalizeAmountFen(value: unknown, fallback: number): number { const text = String(value ?? '').trim() if (!text) return fallback diff --git a/apps/backend/src/services/worker-platform/worker-auth-utils.ts b/apps/backend/src/services/worker-platform/worker-auth-utils.ts new file mode 100644 index 00000000..96951879 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-auth-utils.ts @@ -0,0 +1,63 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +import type { WorkerUserRow } from '../../repositories/worker-platform/index.js' +import { createHttpError } from '../../utils/http.js' +import { addHours, nowIso } from '../../utils/time.js' +import { normalizeSessionVersion } from './worker-normalizers.js' + +export function createWorkerSessionToken(worker: WorkerUserRow) { + const issuedAt = nowIso() + const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12)) + const payload = { + typ: 'worker', + sid: crypto.randomBytes(12).toString('hex'), + uid: Number(worker.id), + usr: worker.username, + status: worker.status, + ver: normalizeSessionVersion(worker.session_version), + iat: issuedAt, + exp: expiresAt, + } + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') + return { + sessionId: payload.sid, + token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`, + expiresAt, + } +} + +export function ensureWorkerAuthConfigured() { + if (String(runtimeConfig.admin?.sessionSecret || '').trim()) return + + throw createHttpError('接单登录态签名密钥未配置,请设置 ADMIN_SESSION_SECRET', { + statusCode: 503, + errorCode: 'worker_auth_not_configured', + }) +} + +export function hashWorkerPassword(password: string): string { + const salt = crypto.randomBytes(16).toString('hex') + const derived = crypto.scryptSync(password, salt, 64).toString('hex') + return `scrypt$${salt}$${derived}` +} + +export function verifyWorkerPassword(password: string, storedHash: string): boolean { + const [algorithm, salt, expectedHash] = String(storedHash || '').split('$') + if (algorithm !== 'scrypt' || !salt || !expectedHash) return false + return safeCompare(crypto.scryptSync(password, salt, 64).toString('hex'), expectedHash) +} + +export function signWorkerPayload(encodedPayload: string): string { + return crypto + .createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || '')) + .update(`worker:${encodedPayload}`) + .digest('base64url') +} + +export function safeCompare(input: unknown, expected: unknown): boolean { + const left = Buffer.from(String(input || ''), 'utf8') + const right = Buffer.from(String(expected || ''), 'utf8') + if (left.length !== right.length) return false + return crypto.timingSafeEqual(left, right) +} diff --git a/apps/backend/src/services/worker-platform/worker-normalizers.ts b/apps/backend/src/services/worker-platform/worker-normalizers.ts new file mode 100644 index 00000000..3bbfa477 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-normalizers.ts @@ -0,0 +1,25 @@ +export function normalizeBoolean(value: unknown, fallback: boolean) { + if (typeof value === 'boolean') return value + const text = String(value ?? '') + .trim() + .toLowerCase() + if (!text) return fallback + if (['true', '1', 'yes', 'enabled', 'active', 'on'].includes(text)) return true + if (['false', '0', 'no', 'disabled', 'off'].includes(text)) return false + return fallback +} + +export function normalizeSessionVersion(value: unknown): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 +} + +export function normalizeInteger(value: unknown, fallback: number): number { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : fallback +} + +export function normalizePositiveInteger(value: unknown, fallback: number): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} diff --git a/apps/backend/src/services/worker-platform/worker-permission-utils.ts b/apps/backend/src/services/worker-platform/worker-permission-utils.ts new file mode 100644 index 00000000..e0e201c1 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-permission-utils.ts @@ -0,0 +1,55 @@ +import type { WorkOrderRow, WorkerUserRow } from '../../repositories/worker-platform/index.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { + normalizeBoolean, + normalizeInteger, + normalizePositiveInteger, +} from './worker-normalizers.js' + +export function resolveWorkerPermissions(worker: WorkerUserRow) { + const permission = safeParseJson(worker.level_permission_json) + return { + depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), + maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1), + upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0), + autoAcceptWithoutEvidence: normalizeBoolean(permission.autoAcceptWithoutEvidence, false), + visibleDelaySeconds: resolveVisibleDelaySeconds( + worker.level_key || '', + permission.visibleDelaySeconds, + ), + } +} + +/** 整单接单与拼单参与走不同验收路径,拼单配置不能影响整单的 VIP 免审核资格。 */ +export function canWorkerAutoAcceptWholeOrder(input: { + worker: WorkerUserRow + hasPersonalSharingShare: boolean +}): boolean { + return ( + !input.hasPersonalSharingShare && + resolveWorkerPermissions(input.worker).autoAcceptWithoutEvidence + ) +} + +export function resolveVisibleDelaySeconds(levelKey: string, configured?: unknown): number { + const configuredValue = normalizeInteger(configured, -1) + if (configuredValue >= 0) return configuredValue + const defaultDelays: Record = { + vip1: 20, + vip2: 15, + vip3: 10, + vip4: 5, + vip5: 0, + } + return defaultDelays[String(levelKey || '').toLowerCase()] ?? 0 +} + +export function resolveFreezeDepositAmount( + workOrder: WorkOrderRow, + permissions: { depositFreeAmount: number }, +) { + return Math.max( + 0, + Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0), + ) +} diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 2c84c6ae..b4636058 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -87,6 +87,12 @@ import { getSmsProvider } from '../sms/index.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' +import { + getRequiredWorkOrder, + getRequiredWorker, + requireActiveWorkerSession, + type WorkerSession, +} from './worker-session-context-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { createWorkerAcceptanceAdminNotification, @@ -158,14 +164,12 @@ const WORKER_DAILY_WITHDRAW_LIMIT = 3 const VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS = 24 const WORKER_SMS_MAX_VERIFY_ATTEMPTS = 5 -export type WorkerSession = { - sessionId: string - workerId: number - username: string - status: string - expiresAt: string - sessionVersion: number -} +export { + getRequiredWorkOrder, + getRequiredWorker, + requireActiveWorkerSession, +} from './worker-session-context-service.js' +export type { WorkerSession } from './worker-session-context-service.js' const WORKER_MAX_DEVICES = 3 @@ -943,17 +947,6 @@ export async function getWorkerSessionSummary(token: unknown) { } } -export function requireActiveWorkerSession(session: WorkerSession | null | undefined) { - if (session?.status === 'active') { - return - } - - throw createHttpError('接单账号尚未通过审核', { - statusCode: 403, - errorCode: 'worker_not_active', - }) -} - export async function getWorkerProfile(session: WorkerSession) { const worker = await getRequiredWorker(session.workerId) const [ @@ -2979,25 +2972,3 @@ export function resolveCollectSubmitTargetWorkOrder( }, }) } - -export async function getRequiredWorker(workerId: number | string): Promise { - const worker = await getWorkerUserById(workerId) - if (!worker) { - throw createHttpError('接单账号不存在', { - statusCode: 404, - errorCode: 'worker_not_found', - }) - } - return worker -} - -export async function getRequiredWorkOrder(workOrderId: number | string): Promise { - const workOrder = await getWorkOrderById(workOrderId) - if (!workOrder) { - throw createHttpError('接单订单不存在', { - statusCode: 404, - errorCode: 'work_order_not_found', - }) - } - return workOrder -} diff --git a/apps/backend/src/services/worker-platform/worker-session-context-service.ts b/apps/backend/src/services/worker-platform/worker-session-context-service.ts new file mode 100644 index 00000000..f925543a --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-session-context-service.ts @@ -0,0 +1,47 @@ +import { + getWorkOrderById, + getWorkerUserById, + type WorkOrderRow, + type WorkerUserRow, +} from '../../repositories/worker-platform/index.js' +import { createHttpError } from '../../utils/http.js' + +export type WorkerSession = { + sessionId: string + workerId: number + username: string + status: string + expiresAt: string + sessionVersion: number +} + +export function requireActiveWorkerSession(session: WorkerSession | null | undefined) { + if (session?.status === 'active') return + + throw createHttpError('接单账号尚未通过审核', { + statusCode: 403, + errorCode: 'worker_not_active', + }) +} + +export async function getRequiredWorker(workerId: number | string): Promise { + const worker = await getWorkerUserById(workerId) + if (!worker) { + throw createHttpError('接单账号不存在', { + statusCode: 404, + errorCode: 'worker_not_found', + }) + } + return worker +} + +export async function getRequiredWorkOrder(workOrderId: number | string): Promise { + const workOrder = await getWorkOrderById(workOrderId) + if (!workOrder) { + throw createHttpError('接单订单不存在', { + statusCode: 404, + errorCode: 'work_order_not_found', + }) + } + return workOrder +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderPanelParts.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderPanelParts.tsx new file mode 100644 index 00000000..f6eb426e --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/WorkOrderPanelParts.tsx @@ -0,0 +1,157 @@ +import { PlusOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { App, Button, Card, Form, Input, InputNumber, Select, Switch } from 'antd' +import { useState } from 'react' + +import { createAdminMockWorkOrder } from '@/services/admin' +import { fetchAdminKuaishouIndustryShops } from '@/services/admin/kuaishou-industry' +import type { WorkOrderStatistics } from '@/types/worker-platform' +import { buildMockWorkOrderShopOptions } from '@/utils/mock-work-order' + +import { formatMoney, formatStatus } from './shared' + +const WORK_ORDER_STATISTICS_STATUS_ORDER = [ + 'pending_material', + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'vip_evidence_pending', + 'problem', + 'accepted', + 'cancelled', +] + +export function WorkOrderStatisticsBar({ statistics }: { statistics?: WorkOrderStatistics }) { + if (!statistics) return null + const profit = Number(statistics.userPaymentAmount || 0) - Number(statistics.rewardAmount || 0) + const statusItems = WORK_ORDER_STATISTICS_STATUS_ORDER.map((status) => ({ + status, + count: Number(statistics.statusCounts?.[status] || 0), + })) + const otherStatusItems = Object.entries(statistics.statusCounts || {}) + .filter(([status]) => !WORK_ORDER_STATISTICS_STATUS_ORDER.includes(status)) + .map(([status, count]) => ({ status, count: Number(count || 0) })) + + return ( + +
+ 本次筛选统计共 {statistics.totalCount} 笔,预计总发单价{' '} + {formatMoney(statistics.rewardAmount)}(不含销售提成),总接单价{' '} + {formatMoney(statistics.userPaymentAmount)},总退款{' '} + {formatMoney(statistics.refundAmount)},预计利润{' '} + {formatMoney(profit)}。 +
+
+ 其中: + {[...statusItems, ...otherStatusItems].map(({ status, count }, index) => ( + + {index > 0 ? ',' : ''} + {formatStatus(status)} + {count}笔 + + ))} +
+
+ ) +} + +export function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { + const { message } = App.useApp() + const [loading, setLoading] = useState(false) + const shopsQuery = useQuery({ + queryKey: ['admin-kuaishou-industry-shops', 'mock-work-order'], + queryFn: fetchAdminKuaishouIndustryShops, + }) + const shopOptions = buildMockWorkOrderShopOptions(shopsQuery.data?.data.shops || []) + + async function submit(values: { + platformOrderId: string + sellerId: string + productName: string + rewardAmount: number + paymentAmount: number + materialComplete?: boolean + }) { + setLoading(true) + try { + await createAdminMockWorkOrder(values) + message.success('Mock 工单已生成') + await onCreated() + } catch (error) { + message.error(error instanceof Error ? error.message : '生成失败') + } finally { + setLoading(false) + } + } + + return ( +
+ + + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx new file mode 100644 index 00000000..1bc923b6 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx @@ -0,0 +1,204 @@ +import { App, Input, InputNumber, Typography } from 'antd' +import { useRef, useState } from 'react' + +import { updateAdminWorkOrder } from '@/services/admin' +import type { WorkOrder } from '@/types/worker-platform' + +import { formatMoney } from './shared' +import { getUserPaymentFen } from './work-order-panel-utils' + +/** 商品名:单击看详情,双击原地修改(仅未接走工单) */ +export function ProductNameEditableCell({ + order, + onOpenDetail, + onSaved, +}: { + order: WorkOrder + onOpenDetail: () => void + onSaved: () => Promise | void +}) { + const { message } = App.useApp() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const savingRef = useRef(false) + const clickTimerRef = useRef(null) + const editable = order.capabilities?.canEdit === true + + function handleClick() { + if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current) + clickTimerRef.current = window.setTimeout(() => onOpenDetail(), 260) + } + + function handleDoubleClick() { + if (clickTimerRef.current) { + window.clearTimeout(clickTimerRef.current) + clickTimerRef.current = null + } + if (!editable) return + setDraft(order.productName || '') + setEditing(true) + } + + async function save() { + if (savingRef.current) return + const next = draft.trim() + if (!next) { + message.error('商品名不能为空') + return + } + if (next === order.productName) { + setEditing(false) + return + } + savingRef.current = true + try { + await updateAdminWorkOrder(order.workOrderId, { productName: next }) + message.success('商品名已更新') + setEditing(false) + await onSaved() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } finally { + savingRef.current = false + } + } + + if (!editing) { + return ( + + {order.productName || '未命名商品'} + + ) + } + + return ( + setDraft(event.target.value)} + onPressEnter={() => void save()} + onBlur={() => void save()} + onKeyDown={(event) => { + if (event.key === 'Escape') setEditing(false) + }} + /> + ) +} + +export function AmountEditableCell({ + order, + field, + onSaved, +}: { + order: WorkOrder + field: 'rewardAmount' | 'requiredDepositAmount' + onSaved: () => Promise | void +}) { + const { message, modal } = App.useApp() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(null) + const savingRef = useRef(false) + const editable = order.capabilities?.canEdit === true + const currentFen = Number(order[field] || 0) + const currentYuan = currentFen / 100 + const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null + const isLossOrder = paymentFen !== null && currentFen > paymentFen + + async function doSave(nextYuan: number) { + if (savingRef.current) return + savingRef.current = true + try { + await updateAdminWorkOrder(order.workOrderId, { [field]: nextYuan }) + message.success('金额已更新') + setEditing(false) + await onSaved() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } finally { + savingRef.current = false + } + } + + async function save() { + if (savingRef.current) return + if (draft === null || !Number.isFinite(Number(draft))) { + setEditing(false) + return + } + const nextYuan = Number(draft) + if (nextYuan < 0) { + message.error('金额不能为负数') + return + } + if (Math.abs(nextYuan - currentYuan) < 0.005) { + setEditing(false) + return + } + const nextFen = Math.round(nextYuan * 100) + if (paymentFen !== null && nextFen > paymentFen) { + modal.confirm({ + title: '发单金额高于用户付款金额', + content: ( + + 该订单用户付款 {formatMoney(paymentFen)},发单金额{' '} + + {formatMoney(nextFen)} + + ,本单平台将亏损。确认保存? + + ), + okText: '确认保存', + cancelText: '取消', + onOk: () => doSave(nextYuan), + }) + return + } + await doSave(nextYuan) + } + + if (!editing) { + return ( + { + if (!editable) return + setDraft(currentYuan) + setEditing(true) + }} + > + {formatMoney(currentFen)} + + ) + } + + return ( + event.target.select()} + onPressEnter={() => void save()} + onBlur={() => void save()} + onKeyDown={(event) => { + if (event.key === 'Escape') setEditing(false) + }} + /> + ) +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index a17c652f..87b9fe9b 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -3,7 +3,6 @@ import { DeleteOutlined, EditOutlined, FormOutlined, - PlusOutlined, PushpinOutlined, ReloadOutlined, SearchOutlined, @@ -51,7 +50,6 @@ import { assignAdminWorkOrderToWorker, cancelAdminWorkOrder, cancelAdminWorkOrderShare, - createAdminMockWorkOrder, deductAdminWorkOrderPendingDeposit, deleteAdminWorkOrder, fetchAdminWorkCategories, @@ -69,18 +67,10 @@ import { updateAdminWorkOrder, updateAdminWorkOrderSharing, } from '@/services/admin' -import { fetchAdminKuaishouIndustryShops } from '@/services/admin/kuaishou-industry' -import type { - CollectField, - UploadedFile, - WorkOrder, - WorkOrderShare, - WorkOrderStatistics, -} from '@/types/worker-platform' +import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' -import { buildMockWorkOrderShopOptions } from '@/utils/mock-work-order' import { DeadlineCountdown, formatTimeoutPolicyLabel, @@ -100,6 +90,18 @@ import { resolveSharingShareStatusColor, resolveStatusColor, } from './shared' +import { AmountEditableCell, ProductNameEditableCell } from './WorkOrderTableCells' +import { MockWorkOrderForm, WorkOrderStatisticsBar } from './WorkOrderPanelParts' +import { + formatRequirementFieldsText, + formatUserPaymentAmount, + getDisplayOrderNo, + getUserPaymentFen, + parseWorkerMaterialText, + renderWorkOrderWorkers, + setSkipPublishConfirm, + shouldSkipPublishConfirm, +} from './work-order-panel-utils' const FILTERABLE_WORK_ORDER_STATUSES = [ 'pending_material', @@ -114,8 +116,6 @@ const FILTERABLE_WORK_ORDER_STATUSES = [ ] as const const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending' -const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm' - type MaterialFormValues = { fields?: Record rewardAmount?: number @@ -1963,445 +1963,3 @@ export default function WorkOrdersPanel() { ) } - -const WORK_ORDER_STATISTICS_STATUS_ORDER = [ - 'pending_material', - 'unassigned', - 'open', - 'in_progress', - 'pending_acceptance', - 'vip_evidence_pending', - 'problem', - 'accepted', - 'cancelled', -] - -function WorkOrderStatisticsBar({ statistics }: { statistics?: WorkOrderStatistics }) { - if (!statistics) return null - const profit = Number(statistics.userPaymentAmount || 0) - Number(statistics.rewardAmount || 0) - const statusItems = WORK_ORDER_STATISTICS_STATUS_ORDER.map((status) => ({ - status, - count: Number(statistics.statusCounts?.[status] || 0), - })) - const otherStatusItems = Object.entries(statistics.statusCounts || {}) - .filter(([status]) => !WORK_ORDER_STATISTICS_STATUS_ORDER.includes(status)) - .map(([status, count]) => ({ status, count: Number(count || 0) })) - - return ( - -
- 本次筛选统计共 {statistics.totalCount} 笔,预计总发单价{' '} - {formatMoney(statistics.rewardAmount)}(不含销售提成),总接单价{' '} - {formatMoney(statistics.userPaymentAmount)},总退款{' '} - {formatMoney(statistics.refundAmount)},预计利润{' '} - {formatMoney(profit)}。 -
-
- 其中: - {[...statusItems, ...otherStatusItems].map(({ status, count }, index) => ( - - {index > 0 ? ',' : ''} - {formatStatus(status)} - {count}笔 - - ))} -
-
- ) -} - -function shouldSkipPublishConfirm() { - try { - return localStorage.getItem(SKIP_PUBLISH_CONFIRM_KEY) === '1' - } catch { - return false - } -} - -function setSkipPublishConfirm() { - try { - localStorage.setItem(SKIP_PUBLISH_CONFIRM_KEY, '1') - } catch { - // 浏览器禁用本地存储时继续发布,但不记住本次选择。 - } -} - -function formatUserPaymentAmount(order: WorkOrder): string { - const paymentFen = getUserPaymentFen(order) - return paymentFen === null ? '-' : formatMoney(paymentFen) -} - -/** 拼单母单没有单一 assigned worker,改为展示当前参与打手及其承接份数。 */ -function renderWorkOrderWorkers(order: WorkOrder) { - if (order.worker) return order.worker.displayName || order.worker.username || '-' - if (!order.sharing?.enabled || !order.sharingWorkers?.length) return '-' - const names = order.sharingWorkers - .map((worker) => worker.displayName || worker.username || `打手#${worker.workerId}`) - .join('、') - const quantity = order.sharingWorkers.reduce( - (sum, worker) => sum + Number(worker.quantity || 0), - 0, - ) - return ( -
- {names} - - {order.sharingWorkers.length} 人 · {quantity} 份 - -
- ) -} - -function getDisplayOrderNo(order: Pick | null | undefined): string { - return String(order?.platformOrderId || '').trim() || '-' -} - -/** 用户付款金额(分);未知返回 null(不参与超价提示) */ -function getUserPaymentFen(order: WorkOrder): number | null { - const material = asRecord(order.material) - const source = asRecord(material.source) - const sendCode = asRecord(source.kuaishouSendCode) - const paymentFen = Number(sendCode.paymentFen ?? source.orderAmountFen) - return Number.isFinite(paymentFen) ? paymentFen : null -} - -/** 商品名:单击看详情,双击原地修改(仅未接走工单) */ -function ProductNameEditableCell({ - order, - onOpenDetail, - onSaved, -}: { - order: WorkOrder - onOpenDetail: () => void - onSaved: () => Promise | void -}) { - const { message } = App.useApp() - const [editing, setEditing] = useState(false) - const [draft, setDraft] = useState('') - const savingRef = useRef(false) - const clickTimerRef = useRef(null) - - const editable = order.capabilities?.canEdit === true - - function handleClick() { - if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current) - clickTimerRef.current = window.setTimeout(() => onOpenDetail(), 260) - } - - function handleDoubleClick() { - if (clickTimerRef.current) { - window.clearTimeout(clickTimerRef.current) - clickTimerRef.current = null - } - if (!editable) return - setDraft(order.productName || '') - setEditing(true) - } - - async function save() { - if (savingRef.current) return - const next = draft.trim() - if (!next) { - message.error('商品名不能为空') - return - } - if (next === order.productName) { - setEditing(false) - return - } - savingRef.current = true - try { - await updateAdminWorkOrder(order.workOrderId, { productName: next }) - message.success('商品名已更新') - setEditing(false) - await onSaved() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存失败') - } finally { - savingRef.current = false - } - } - - if (!editing) { - return ( - - {order.productName || '未命名商品'} - - ) - } - - return ( - setDraft(event.target.value)} - onPressEnter={() => void save()} - onBlur={() => void save()} - onKeyDown={(event) => { - if (event.key === 'Escape') setEditing(false) - }} - /> - ) -} - -function AmountEditableCell({ - order, - field, - onSaved, -}: { - order: WorkOrder - field: 'rewardAmount' | 'requiredDepositAmount' - onSaved: () => Promise | void -}) { - const { message, modal } = App.useApp() - const [editing, setEditing] = useState(false) - const [draft, setDraft] = useState(null) - const savingRef = useRef(false) - - const editable = order.capabilities?.canEdit === true - const currentFen = Number(order[field] || 0) - const currentYuan = currentFen / 100 - const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null - const isLossOrder = paymentFen !== null && currentFen > paymentFen - - async function doSave(nextYuan: number) { - if (savingRef.current) return - savingRef.current = true - try { - await updateAdminWorkOrder(order.workOrderId, { [field]: nextYuan }) - message.success('金额已更新') - setEditing(false) - await onSaved() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存失败') - } finally { - savingRef.current = false - } - } - - async function save() { - if (savingRef.current) return - if (draft === null || !Number.isFinite(Number(draft))) { - setEditing(false) - return - } - const nextYuan = Number(draft) - if (nextYuan < 0) { - message.error('金额不能为负数') - return - } - if (Math.abs(nextYuan - currentYuan) < 0.005) { - setEditing(false) - return - } - const nextFen = Math.round(nextYuan * 100) - if (paymentFen !== null && nextFen > paymentFen) { - modal.confirm({ - title: '发单金额高于用户付款金额', - content: ( - - 该订单用户付款 {formatMoney(paymentFen)},发单金额{' '} - - {formatMoney(nextFen)} - - ,本单平台将亏损。确认保存? - - ), - okText: '确认保存', - cancelText: '取消', - onOk: () => doSave(nextYuan), - }) - return - } - await doSave(nextYuan) - } - - if (!editing) { - return ( - { - if (!editable) return - setDraft(currentYuan) - setEditing(true) - }} - > - {formatMoney(currentFen)} - - ) - } - - return ( - event.target.select()} - onPressEnter={() => void save()} - onBlur={() => void save()} - onKeyDown={(event) => { - if (event.key === 'Escape') setEditing(false) - }} - /> - ) -} - -/** 解析客服粘贴文本(如「安卓还是苹果(必填):安卓」),返回字段 key → 值 */ -function parseWorkerMaterialText(text: string): Record { - const result: Record = {} - const lines = String(text || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const line of lines) { - const match = line.match(/^(.+?)[::]\s*(.+)$/) - if (!match) continue - const label = match[1] - .replace(/[((](必填|选填)[))]/g, '') - .trim() - .toLowerCase() - const value = match[2].trim() - if (!label || !value) continue - - if (/系统|安卓|苹果/.test(label)) { - if (value.includes('苹果')) result.system = '苹果' - else if (value.includes('安卓')) result.system = '安卓' - } else if (/区/.test(label) || /服/.test(label)) { - const zoneValue = value.toLowerCase() - if (zoneValue.includes('微信') || /^v/.test(zoneValue)) result.serverZone = '微信区' - else if (zoneValue.includes('q')) result.serverZone = 'QQ区' - } else if (/角色|昵称|名称/.test(label)) { - result.gameNickname = value - } else if (/编号|游戏id|账号id|^id|uid/.test(label)) { - result.gameId = value - } - } - return result -} - -function formatRequirementFieldsText(fields: CollectField[]): string { - return fields - .map((field) => { - const labelPart = `${field.key}:${field.label}` - const options = (field.options || []).filter(Boolean) - return options.length > 0 ? `${labelPart}#${options.join(',')}` : labelPart - }) - .join('\n') -} - -function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { - const { message } = App.useApp() - const [loading, setLoading] = useState(false) - const shopsQuery = useQuery({ - queryKey: ['admin-kuaishou-industry-shops', 'mock-work-order'], - queryFn: fetchAdminKuaishouIndustryShops, - }) - const shopOptions = buildMockWorkOrderShopOptions(shopsQuery.data?.data.shops || []) - - async function submit(values: { - platformOrderId: string - sellerId: string - productName: string - rewardAmount: number - paymentAmount: number - materialComplete?: boolean - }) { - setLoading(true) - try { - await createAdminMockWorkOrder(values) - message.success('Mock 工单已生成') - await onCreated() - } catch (error) { - message.error(error instanceof Error ? error.message : '生成失败') - } finally { - setLoading(false) - } - } - - return ( -
- - - - - - - - - - - - - - - - - - -
- ) -} diff --git a/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx b/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx new file mode 100644 index 00000000..9b17c58e --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx @@ -0,0 +1,107 @@ +import { Typography } from 'antd' + +import type { CollectField, WorkOrder } from '@/types/worker-platform' + +import { asRecord, formatMoney } from './shared' + +const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm' + +export function shouldSkipPublishConfirm() { + try { + return localStorage.getItem(SKIP_PUBLISH_CONFIRM_KEY) === '1' + } catch { + return false + } +} + +export function setSkipPublishConfirm() { + try { + localStorage.setItem(SKIP_PUBLISH_CONFIRM_KEY, '1') + } catch { + // 浏览器禁用本地存储时继续发布,但不记住本次选择。 + } +} + +export function formatUserPaymentAmount(order: WorkOrder): string { + const paymentFen = getUserPaymentFen(order) + return paymentFen === null ? '-' : formatMoney(paymentFen) +} + +/** 拼单母单没有单一 assigned worker,改为展示当前参与打手及其承接份数。 */ +export function renderWorkOrderWorkers(order: WorkOrder) { + if (order.worker) return order.worker.displayName || order.worker.username || '-' + if (!order.sharing?.enabled || !order.sharingWorkers?.length) return '-' + const names = order.sharingWorkers + .map((worker) => worker.displayName || worker.username || `打手#${worker.workerId}`) + .join('、') + const quantity = order.sharingWorkers.reduce( + (sum, worker) => sum + Number(worker.quantity || 0), + 0, + ) + return ( +
+ {names} + + {order.sharingWorkers.length} 人 · {quantity} 份 + +
+ ) +} + +export function getDisplayOrderNo( + order: Pick | null | undefined, +): string { + return String(order?.platformOrderId || '').trim() || '-' +} + +/** 用户付款金额(分);未知返回 null(不参与超价提示) */ +export function getUserPaymentFen(order: WorkOrder): number | null { + const material = asRecord(order.material) + const source = asRecord(material.source) + const sendCode = asRecord(source.kuaishouSendCode) + const paymentFen = Number(sendCode.paymentFen ?? source.orderAmountFen) + return Number.isFinite(paymentFen) ? paymentFen : null +} + +/** 解析客服粘贴文本(如「安卓还是苹果(必填):安卓」),返回字段 key → 值 */ +export function parseWorkerMaterialText(text: string): Record { + const result: Record = {} + const lines = String(text || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + for (const line of lines) { + const match = line.match(/^(.+?)[::]\s*(.+)$/) + if (!match) continue + const label = match[1] + .replace(/[((](必填|选填)[))]/g, '') + .trim() + .toLowerCase() + const value = match[2].trim() + if (!label || !value) continue + + if (/系统|安卓|苹果/.test(label)) { + if (value.includes('苹果')) result.system = '苹果' + else if (value.includes('安卓')) result.system = '安卓' + } else if (/区/.test(label) || /服/.test(label)) { + const zoneValue = value.toLowerCase() + if (zoneValue.includes('微信') || /^v/.test(zoneValue)) result.serverZone = '微信区' + else if (zoneValue.includes('q')) result.serverZone = 'QQ区' + } else if (/角色|昵称|名称/.test(label)) { + result.gameNickname = value + } else if (/编号|游戏id|账号id|^id|uid/.test(label)) { + result.gameId = value + } + } + return result +} + +export function formatRequirementFieldsText(fields: CollectField[]): string { + return fields + .map((field) => { + const labelPart = `${field.key}:${field.label}` + const options = (field.options || []).filter(Boolean) + return options.length > 0 ? `${labelPart}#${options.join(',')}` : labelPart + }) + .join('\n') +}