拆分打手平台通用模块
This commit is contained in:
@@ -98,12 +98,8 @@ import {
|
|||||||
resolveWorkerPermissions,
|
resolveWorkerPermissions,
|
||||||
resolveWorkOrderOperationalStatus,
|
resolveWorkOrderOperationalStatus,
|
||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
import {
|
import { ensureWorkerPlatformDefaults, normalizeWorkOrderTimeoutPolicy } from './worker-service.js'
|
||||||
ensureWorkerPlatformDefaults,
|
import { getRequiredWorkOrder, getRequiredWorker } from './worker-session-context-service.js'
|
||||||
getRequiredWorkOrder,
|
|
||||||
getRequiredWorker,
|
|
||||||
normalizeWorkOrderTimeoutPolicy,
|
|
||||||
} from './worker-service.js'
|
|
||||||
import { resolveSharingQuantity } from './admin-worker-catalog-service.js'
|
import { resolveSharingQuantity } from './admin-worker-catalog-service.js'
|
||||||
import {
|
import {
|
||||||
getKuaishouIndustrySourceConfig,
|
getKuaishouIndustrySourceConfig,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
normalizeProofFiles,
|
normalizeProofFiles,
|
||||||
normalizeWithdrawChannel,
|
normalizeWithdrawChannel,
|
||||||
} from './mappers.js'
|
} 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'
|
import { getWorkerFinanceConfig, saveWorkerFinanceConfig } from './worker-finance-config-service.js'
|
||||||
|
|
||||||
function mapAdminWorkerWithdrawalAccount(account: WorkerWithdrawalAccountRow) {
|
function mapAdminWorkerWithdrawalAccount(account: WorkerWithdrawalAccountRow) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
normalizeProofFiles,
|
normalizeProofFiles,
|
||||||
normalizeUploadedFiles,
|
normalizeUploadedFiles,
|
||||||
} from './mappers.js'
|
} 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 = {}) {
|
export async function listAdminAfterSalesCases(query: JsonObject = {}) {
|
||||||
const page = normalizePage(query.page)
|
const page = normalizePage(query.page)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './mappers.js'
|
export * from './mappers.js'
|
||||||
|
export * from './worker-session-context-service.js'
|
||||||
export * from './worker-service.js'
|
export * from './worker-service.js'
|
||||||
export * from './after-sales-service.js'
|
export * from './after-sales-service.js'
|
||||||
export * from './admin-service.js'
|
export * from './admin-service.js'
|
||||||
|
|||||||
@@ -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 { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||||
import {
|
import {
|
||||||
addWorkerWalletCredit,
|
addWorkerWalletCredit,
|
||||||
@@ -21,13 +18,45 @@ import {
|
|||||||
import type { JsonObject } from '../../types/json.js'
|
import type { JsonObject } from '../../types/json.js'
|
||||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { nowIso, addHours } from '../../utils/time.js'
|
|
||||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||||
import {
|
import {
|
||||||
normalizeStoredFileUrl,
|
normalizeStoredFileUrl,
|
||||||
refreshStoredFileUrl,
|
refreshStoredFileUrl,
|
||||||
refreshUploadedFileUrls,
|
refreshUploadedFileUrls,
|
||||||
} from '../file-storage/file-storage-service.js'
|
} 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_KEY = 'vip1'
|
||||||
export const DEFAULT_LEVEL_NAME = '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_PER_WINDOW = 5
|
||||||
export const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000
|
export const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
export function createWorkerSession(worker: WorkerUserRow) {
|
export function createWorkerSession(worker: WorkerUserRow) {
|
||||||
const issuedAt = nowIso()
|
const session = createWorkerSessionToken(worker)
|
||||||
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 {
|
return {
|
||||||
sessionId: payload.sid,
|
...session,
|
||||||
token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`,
|
|
||||||
expiresAt,
|
|
||||||
worker: mapWorkerUser(worker),
|
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<string, number> = {
|
|
||||||
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) {
|
export function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
|
||||||
if (!level) return null
|
if (!level) return null
|
||||||
const permission = safeParseJson(level.permission_json)
|
const permission = safeParseJson(level.permission_json)
|
||||||
@@ -1637,17 +1564,6 @@ export function normalizeMatchType(value: unknown) {
|
|||||||
return 'contains'
|
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) {
|
export function normalizeSlugKey(value: unknown, fallback: string) {
|
||||||
const key = String(value || '')
|
const key = String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -1689,26 +1605,11 @@ export function isWorkerPasswordStrong(password: string): boolean {
|
|||||||
return password.length >= 8 && /[a-zA-Z]/.test(password) && /\d/.test(password)
|
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 {
|
export function normalizeOptionalId(value: unknown): number | null {
|
||||||
const parsed = Number(value)
|
const parsed = Number(value)
|
||||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
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 {
|
export function normalizeAmountFen(value: unknown, fallback: number): number {
|
||||||
const text = String(value ?? '').trim()
|
const text = String(value ?? '').trim()
|
||||||
if (!text) return fallback
|
if (!text) return fallback
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<string, number> = {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -87,6 +87,12 @@ import { getSmsProvider } from '../sms/index.js'
|
|||||||
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
|
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
|
||||||
import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js'
|
import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js'
|
||||||
import { getWorkerHallConfig } from './worker-hall-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 { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js'
|
||||||
import {
|
import {
|
||||||
createWorkerAcceptanceAdminNotification,
|
createWorkerAcceptanceAdminNotification,
|
||||||
@@ -158,14 +164,12 @@ const WORKER_DAILY_WITHDRAW_LIMIT = 3
|
|||||||
const VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS = 24
|
const VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS = 24
|
||||||
const WORKER_SMS_MAX_VERIFY_ATTEMPTS = 5
|
const WORKER_SMS_MAX_VERIFY_ATTEMPTS = 5
|
||||||
|
|
||||||
export type WorkerSession = {
|
export {
|
||||||
sessionId: string
|
getRequiredWorkOrder,
|
||||||
workerId: number
|
getRequiredWorker,
|
||||||
username: string
|
requireActiveWorkerSession,
|
||||||
status: string
|
} from './worker-session-context-service.js'
|
||||||
expiresAt: string
|
export type { WorkerSession } from './worker-session-context-service.js'
|
||||||
sessionVersion: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKER_MAX_DEVICES = 3
|
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) {
|
export async function getWorkerProfile(session: WorkerSession) {
|
||||||
const worker = await getRequiredWorker(session.workerId)
|
const worker = await getRequiredWorker(session.workerId)
|
||||||
const [
|
const [
|
||||||
@@ -2979,25 +2972,3 @@ export function resolveCollectSubmitTargetWorkOrder(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRequiredWorker(workerId: number | string): Promise<WorkerUserRow> {
|
|
||||||
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<WorkOrderRow> {
|
|
||||||
const workOrder = await getWorkOrderById(workOrderId)
|
|
||||||
if (!workOrder) {
|
|
||||||
throw createHttpError('接单订单不存在', {
|
|
||||||
statusCode: 404,
|
|
||||||
errorCode: 'work_order_not_found',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return workOrder
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<WorkerUserRow> {
|
||||||
|
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<WorkOrderRow> {
|
||||||
|
const workOrder = await getWorkOrderById(workOrderId)
|
||||||
|
if (!workOrder) {
|
||||||
|
throw createHttpError('接单订单不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'work_order_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return workOrder
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card size="small" bordered={false} className="worker-orders-statistics-card">
|
||||||
|
<div className="worker-orders-statistics-summary">
|
||||||
|
本次筛选统计共 <strong>{statistics.totalCount}</strong> 笔,预计总发单价{' '}
|
||||||
|
<strong>{formatMoney(statistics.rewardAmount)}</strong>(不含销售提成),总接单价{' '}
|
||||||
|
<strong>{formatMoney(statistics.userPaymentAmount)}</strong>,总退款{' '}
|
||||||
|
<strong>{formatMoney(statistics.refundAmount)}</strong>,预计利润{' '}
|
||||||
|
<strong className={profit < 0 ? 'is-negative' : ''}>{formatMoney(profit)}</strong>。
|
||||||
|
</div>
|
||||||
|
<div className="worker-orders-statistics-statuses">
|
||||||
|
其中:
|
||||||
|
{[...statusItems, ...otherStatusItems].map(({ status, count }, index) => (
|
||||||
|
<span key={status}>
|
||||||
|
{index > 0 ? ',' : ''}
|
||||||
|
{formatStatus(status)}
|
||||||
|
{count}笔
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||||
|
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 (
|
||||||
|
<Form className="worker-order-mock-form" layout="inline" onFinish={submit}>
|
||||||
|
<Form.Item
|
||||||
|
className="worker-order-mock-field-order"
|
||||||
|
label="订单号"
|
||||||
|
name="platformOrderId"
|
||||||
|
rules={[{ required: true, whitespace: true, message: '请填写订单号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入订单号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
className="worker-order-mock-field-shop"
|
||||||
|
label="店铺"
|
||||||
|
name="sellerId"
|
||||||
|
rules={[{ required: true, message: '请选择店铺' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
loading={shopsQuery.isLoading}
|
||||||
|
options={shopOptions}
|
||||||
|
placeholder={shopOptions.length > 0 ? '请选择店铺' : '暂无可用店铺'}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
className="worker-order-mock-field-product"
|
||||||
|
label="商品"
|
||||||
|
name="productName"
|
||||||
|
rules={[{ required: true, whitespace: true, message: '请填写商品名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入商品名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
className="worker-order-mock-field-amount"
|
||||||
|
label="接单金额"
|
||||||
|
name="rewardAmount"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写接单金额' },
|
||||||
|
{ type: 'number', min: 0.01, message: '接单金额必须大于 0' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
className="worker-order-mock-field-amount"
|
||||||
|
label="用户付款金额"
|
||||||
|
name="paymentAmount"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写用户付款金额' },
|
||||||
|
{ type: 'number', min: 0.01, message: '用户付款金额必须大于 0' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
className="worker-order-mock-field-complete"
|
||||||
|
label="资料完整"
|
||||||
|
name="materialComplete"
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item className="worker-order-mock-action">
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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> | void
|
||||||
|
}) {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const savingRef = useRef(false)
|
||||||
|
const clickTimerRef = useRef<number | null>(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 (
|
||||||
|
<Typography.Link
|
||||||
|
title={editable ? '双击修改商品名' : undefined}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
{order.productName || '未命名商品'}
|
||||||
|
</Typography.Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
size="small"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => 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> | void
|
||||||
|
}) {
|
||||||
|
const { message, modal } = App.useApp()
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<number | null>(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: (
|
||||||
|
<span>
|
||||||
|
该订单用户付款 {formatMoney(paymentFen)},发单金额{' '}
|
||||||
|
<Typography.Text strong type="danger">
|
||||||
|
{formatMoney(nextFen)}
|
||||||
|
</Typography.Text>
|
||||||
|
,本单平台将亏损。确认保存?
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
okText: '确认保存',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => doSave(nextYuan),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await doSave(nextYuan)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
title={editable ? '双击修改金额' : undefined}
|
||||||
|
strong={isLossOrder}
|
||||||
|
type={isLossOrder ? 'danger' : undefined}
|
||||||
|
style={{
|
||||||
|
cursor: editable ? 'pointer' : 'default',
|
||||||
|
...(editable && !isLossOrder ? { color: 'rgba(0,0,0,0.65)' } : {}),
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (!editable) return
|
||||||
|
setDraft(currentYuan)
|
||||||
|
setEditing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatMoney(currentFen)}
|
||||||
|
</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
autoFocus
|
||||||
|
size="small"
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
step={1}
|
||||||
|
style={{ width: 92 }}
|
||||||
|
value={draft}
|
||||||
|
onChange={setDraft}
|
||||||
|
onFocus={(event) => event.target.select()}
|
||||||
|
onPressEnter={() => void save()}
|
||||||
|
onBlur={() => void save()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') setEditing(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
PlusOutlined,
|
|
||||||
PushpinOutlined,
|
PushpinOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -51,7 +50,6 @@ import {
|
|||||||
assignAdminWorkOrderToWorker,
|
assignAdminWorkOrderToWorker,
|
||||||
cancelAdminWorkOrder,
|
cancelAdminWorkOrder,
|
||||||
cancelAdminWorkOrderShare,
|
cancelAdminWorkOrderShare,
|
||||||
createAdminMockWorkOrder,
|
|
||||||
deductAdminWorkOrderPendingDeposit,
|
deductAdminWorkOrderPendingDeposit,
|
||||||
deleteAdminWorkOrder,
|
deleteAdminWorkOrder,
|
||||||
fetchAdminWorkCategories,
|
fetchAdminWorkCategories,
|
||||||
@@ -69,18 +67,10 @@ import {
|
|||||||
updateAdminWorkOrder,
|
updateAdminWorkOrder,
|
||||||
updateAdminWorkOrderSharing,
|
updateAdminWorkOrderSharing,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import { fetchAdminKuaishouIndustryShops } from '@/services/admin/kuaishou-industry'
|
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
|
||||||
import type {
|
|
||||||
CollectField,
|
|
||||||
UploadedFile,
|
|
||||||
WorkOrder,
|
|
||||||
WorkOrderShare,
|
|
||||||
WorkOrderStatistics,
|
|
||||||
} from '@/types/worker-platform'
|
|
||||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||||
import { hasAdminRole } from '@/utils/admin-auth'
|
import { hasAdminRole } from '@/utils/admin-auth'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
import { buildMockWorkOrderShopOptions } from '@/utils/mock-work-order'
|
|
||||||
import {
|
import {
|
||||||
DeadlineCountdown,
|
DeadlineCountdown,
|
||||||
formatTimeoutPolicyLabel,
|
formatTimeoutPolicyLabel,
|
||||||
@@ -100,6 +90,18 @@ import {
|
|||||||
resolveSharingShareStatusColor,
|
resolveSharingShareStatusColor,
|
||||||
resolveStatusColor,
|
resolveStatusColor,
|
||||||
} from './shared'
|
} 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 = [
|
const FILTERABLE_WORK_ORDER_STATUSES = [
|
||||||
'pending_material',
|
'pending_material',
|
||||||
@@ -114,8 +116,6 @@ const FILTERABLE_WORK_ORDER_STATUSES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending'
|
const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending'
|
||||||
const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm'
|
|
||||||
|
|
||||||
type MaterialFormValues = {
|
type MaterialFormValues = {
|
||||||
fields?: Record<string, string>
|
fields?: Record<string, string>
|
||||||
rewardAmount?: number
|
rewardAmount?: number
|
||||||
@@ -1963,445 +1963,3 @@ export default function WorkOrdersPanel() {
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<Card size="small" bordered={false} className="worker-orders-statistics-card">
|
|
||||||
<div className="worker-orders-statistics-summary">
|
|
||||||
本次筛选统计共 <strong>{statistics.totalCount}</strong> 笔,预计总发单价{' '}
|
|
||||||
<strong>{formatMoney(statistics.rewardAmount)}</strong>(不含销售提成),总接单价{' '}
|
|
||||||
<strong>{formatMoney(statistics.userPaymentAmount)}</strong>,总退款{' '}
|
|
||||||
<strong>{formatMoney(statistics.refundAmount)}</strong>,预计利润{' '}
|
|
||||||
<strong className={profit < 0 ? 'is-negative' : ''}>{formatMoney(profit)}</strong>。
|
|
||||||
</div>
|
|
||||||
<div className="worker-orders-statistics-statuses">
|
|
||||||
其中:
|
|
||||||
{[...statusItems, ...otherStatusItems].map(({ status, count }, index) => (
|
|
||||||
<span key={status}>
|
|
||||||
{index > 0 ? ',' : ''}
|
|
||||||
{formatStatus(status)}
|
|
||||||
{count}笔
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="cell-stack">
|
|
||||||
<Typography.Text ellipsis={{ tooltip: names }}>{names}</Typography.Text>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{order.sharingWorkers.length} 人 · {quantity} 份
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayOrderNo(order: Pick<WorkOrder, 'platformOrderId'> | 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> | void
|
|
||||||
}) {
|
|
||||||
const { message } = App.useApp()
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [draft, setDraft] = useState('')
|
|
||||||
const savingRef = useRef(false)
|
|
||||||
const clickTimerRef = useRef<number | null>(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 (
|
|
||||||
<Typography.Link
|
|
||||||
title={editable ? '双击修改商品名' : undefined}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
>
|
|
||||||
{order.productName || '未命名商品'}
|
|
||||||
</Typography.Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
size="small"
|
|
||||||
style={{ width: 200 }}
|
|
||||||
value={draft}
|
|
||||||
onChange={(event) => 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> | void
|
|
||||||
}) {
|
|
||||||
const { message, modal } = App.useApp()
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [draft, setDraft] = useState<number | null>(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: (
|
|
||||||
<span>
|
|
||||||
该订单用户付款 {formatMoney(paymentFen)},发单金额{' '}
|
|
||||||
<Typography.Text strong type="danger">
|
|
||||||
{formatMoney(nextFen)}
|
|
||||||
</Typography.Text>
|
|
||||||
,本单平台将亏损。确认保存?
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
okText: '确认保存',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: () => doSave(nextYuan),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await doSave(nextYuan)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editing) {
|
|
||||||
return (
|
|
||||||
<Typography.Text
|
|
||||||
title={editable ? '双击修改金额' : undefined}
|
|
||||||
strong={isLossOrder}
|
|
||||||
type={isLossOrder ? 'danger' : undefined}
|
|
||||||
style={{
|
|
||||||
cursor: editable ? 'pointer' : 'default',
|
|
||||||
...(editable && !isLossOrder ? { color: 'rgba(0,0,0,0.65)' } : {}),
|
|
||||||
}}
|
|
||||||
onDoubleClick={() => {
|
|
||||||
if (!editable) return
|
|
||||||
setDraft(currentYuan)
|
|
||||||
setEditing(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatMoney(currentFen)}
|
|
||||||
</Typography.Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InputNumber
|
|
||||||
autoFocus
|
|
||||||
size="small"
|
|
||||||
min={0}
|
|
||||||
precision={2}
|
|
||||||
step={1}
|
|
||||||
style={{ width: 92 }}
|
|
||||||
value={draft}
|
|
||||||
onChange={setDraft}
|
|
||||||
onFocus={(event) => event.target.select()}
|
|
||||||
onPressEnter={() => void save()}
|
|
||||||
onBlur={() => void save()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') setEditing(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析客服粘贴文本(如「安卓还是苹果(必填):安卓」),返回字段 key → 值 */
|
|
||||||
function parseWorkerMaterialText(text: string): Record<string, string> {
|
|
||||||
const result: Record<string, string> = {}
|
|
||||||
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<void> }) {
|
|
||||||
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 (
|
|
||||||
<Form className="worker-order-mock-form" layout="inline" onFinish={submit}>
|
|
||||||
<Form.Item
|
|
||||||
className="worker-order-mock-field-order"
|
|
||||||
label="订单号"
|
|
||||||
name="platformOrderId"
|
|
||||||
rules={[{ required: true, whitespace: true, message: '请填写订单号' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入订单号" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
className="worker-order-mock-field-shop"
|
|
||||||
label="店铺"
|
|
||||||
name="sellerId"
|
|
||||||
rules={[{ required: true, message: '请选择店铺' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
loading={shopsQuery.isLoading}
|
|
||||||
options={shopOptions}
|
|
||||||
placeholder={shopOptions.length > 0 ? '请选择店铺' : '暂无可用店铺'}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
className="worker-order-mock-field-product"
|
|
||||||
label="商品"
|
|
||||||
name="productName"
|
|
||||||
rules={[{ required: true, whitespace: true, message: '请填写商品名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入商品名称" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
className="worker-order-mock-field-amount"
|
|
||||||
label="接单金额"
|
|
||||||
name="rewardAmount"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请填写接单金额' },
|
|
||||||
{ type: 'number', min: 0.01, message: '接单金额必须大于 0' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
className="worker-order-mock-field-amount"
|
|
||||||
label="用户付款金额"
|
|
||||||
name="paymentAmount"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请填写用户付款金额' },
|
|
||||||
{ type: 'number', min: 0.01, message: '用户付款金额必须大于 0' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
className="worker-order-mock-field-complete"
|
|
||||||
label="资料完整"
|
|
||||||
name="materialComplete"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item className="worker-order-mock-action">
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
|
|
||||||
生成
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text ellipsis={{ tooltip: names }}>{names}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{order.sharingWorkers.length} 人 · {quantity} 份
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayOrderNo(
|
||||||
|
order: Pick<WorkOrder, 'platformOrderId'> | 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<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
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')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user