拆分接单平台超长文件,按域组织代码
- worker-platform-service.ts (2733行) 拆为 worker-service/admin-service/mappers + index 聚合 - worker-platform-repo.ts (2456行) 拆为 repositories/worker-platform/ 目录(types/shared/worker-repo/work-order-repo) - AdminWorkerPlatformPage.tsx (2784行) 拆为主页面 + panels/ 目录(7 个面板文件) - 引用方统一改指向 index 聚合,行为不变
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { resolveOutstandingDepositAmount } from './worker-platform-repo.js'
|
import { resolveOutstandingDepositAmount } from './worker-platform/index.js'
|
||||||
|
|
||||||
test('resolveOutstandingDepositAmount deducts released and deducted history', () => {
|
test('resolveOutstandingDepositAmount deducts released and deducted history', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './types.js'
|
||||||
|
export * from './shared.js'
|
||||||
|
export * from './worker-repo.js'
|
||||||
|
export * from './work-order-repo.js'
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { PoolClient } from 'pg'
|
||||||
|
|
||||||
|
import type { WorkerWalletRow } from './types.js'
|
||||||
|
|
||||||
|
|
||||||
|
export async function ensureWorkerWalletWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
workerId: number,
|
||||||
|
now: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!workerId) return
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_wallets (
|
||||||
|
worker_id, available_amount, frozen_deposit_amount,
|
||||||
|
total_credited_amount, total_settled_amount, created_at, updated_at
|
||||||
|
) VALUES ($1, 0, 0, 0, 0, $2, $3)
|
||||||
|
ON CONFLICT (worker_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
[workerId, now, now],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerWalletWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
workerId: number,
|
||||||
|
): Promise<WorkerWalletRow | null> {
|
||||||
|
const result = await client.query<WorkerWalletRow>(
|
||||||
|
'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1 FOR UPDATE',
|
||||||
|
[workerId],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toJsonString(value: unknown): string {
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return JSON.stringify(value ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPositiveInteger(value: unknown, fallback: number): number {
|
||||||
|
const parsed = Number(value ?? '')
|
||||||
|
if (!Number.isFinite(parsed)) return fallback
|
||||||
|
return Math.max(0, Math.round(parsed))
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export type WorkerLevelRow = {
|
||||||
|
id: number
|
||||||
|
level_key: string
|
||||||
|
name: string
|
||||||
|
sort_order: number
|
||||||
|
status: string
|
||||||
|
permission_json: string | Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerUserRow = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
password_hash: string
|
||||||
|
display_name: string
|
||||||
|
phone: string
|
||||||
|
level_id: number | null
|
||||||
|
status: string
|
||||||
|
review_note: string
|
||||||
|
session_version: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
reviewed_at: string | null
|
||||||
|
inviter_id: number | null
|
||||||
|
invite_code: string
|
||||||
|
inviter_username?: string
|
||||||
|
inviter_display_name?: string
|
||||||
|
level_key?: string
|
||||||
|
level_name?: string
|
||||||
|
level_permission_json?: string | Record<string, unknown>
|
||||||
|
available_amount?: number
|
||||||
|
frozen_deposit_amount?: number
|
||||||
|
total_credited_amount?: number
|
||||||
|
total_settled_amount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerWalletRow = {
|
||||||
|
worker_id: number
|
||||||
|
available_amount: number
|
||||||
|
frozen_deposit_amount: number
|
||||||
|
total_credited_amount: number
|
||||||
|
total_settled_amount: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerWalletLedgerRow = {
|
||||||
|
id: number
|
||||||
|
worker_id: number
|
||||||
|
ledger_type: string
|
||||||
|
amount: number
|
||||||
|
balance_after: number
|
||||||
|
frozen_after: number
|
||||||
|
related_work_order_id: number | null
|
||||||
|
audit_status: string
|
||||||
|
note: string
|
||||||
|
payload_json: string | Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerFinanceRequestRow = {
|
||||||
|
id: number
|
||||||
|
worker_id: number
|
||||||
|
request_type: string
|
||||||
|
status: string
|
||||||
|
amount: number
|
||||||
|
account_channel: string
|
||||||
|
account_name: string
|
||||||
|
account_no: string
|
||||||
|
note: string
|
||||||
|
reviewed_note: string
|
||||||
|
payload_json: string | Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
reviewed_at: string | null
|
||||||
|
worker_username?: string
|
||||||
|
worker_display_name?: string
|
||||||
|
worker_phone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkCategoryRow = {
|
||||||
|
id: number
|
||||||
|
category_key: string
|
||||||
|
name: string
|
||||||
|
sort_order: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkProductRuleRow = {
|
||||||
|
id: number
|
||||||
|
rule_key: string
|
||||||
|
provider: string
|
||||||
|
platform: string
|
||||||
|
shop_id: string
|
||||||
|
sku_code: string
|
||||||
|
product_name: string
|
||||||
|
match_type: string
|
||||||
|
category_id: number | null
|
||||||
|
enabled: boolean
|
||||||
|
auto_create: boolean
|
||||||
|
reward_amount: number
|
||||||
|
required_deposit_amount: number
|
||||||
|
deposit_threshold_amount: number
|
||||||
|
sharing_enabled: boolean
|
||||||
|
sharing_total_quantity: number
|
||||||
|
sharing_unit_reward: number
|
||||||
|
requirement_json: string | Record<string, unknown>
|
||||||
|
sort_order: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
category_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkOrderShareRow = {
|
||||||
|
id: number
|
||||||
|
work_order_id: number
|
||||||
|
worker_id: number
|
||||||
|
quantity: number
|
||||||
|
unit_reward: number
|
||||||
|
share_reward: number
|
||||||
|
share_deposit: number
|
||||||
|
status: string
|
||||||
|
acceptance_json: string | Record<string, unknown>
|
||||||
|
submitted_at: string | null
|
||||||
|
accepted_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
worker_username?: string
|
||||||
|
worker_display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkOrderRow = {
|
||||||
|
id: number
|
||||||
|
work_order_no: string
|
||||||
|
order_id: number | null
|
||||||
|
order_item_id: number | null
|
||||||
|
task_id: number | null
|
||||||
|
platform_order_id: string
|
||||||
|
product_name: string
|
||||||
|
category_id: number | null
|
||||||
|
status: string
|
||||||
|
reward_amount: number
|
||||||
|
required_deposit_amount: number
|
||||||
|
deposit_threshold_amount: number
|
||||||
|
assigned_worker_id: number | null
|
||||||
|
material_json: string | Record<string, unknown>
|
||||||
|
requirement_json: string | Record<string, unknown>
|
||||||
|
acceptance_json: string | Record<string, unknown>
|
||||||
|
problem_note: string
|
||||||
|
sharing_enabled: boolean
|
||||||
|
sharing_total_quantity: number
|
||||||
|
sharing_unit_reward: number
|
||||||
|
published_at: string | null
|
||||||
|
assigned_at: string | null
|
||||||
|
submitted_at: string | null
|
||||||
|
accepted_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
category_name?: string
|
||||||
|
worker_username?: string
|
||||||
|
worker_display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProblemWorkOrderResolutionAction =
|
||||||
|
| 'return_to_worker'
|
||||||
|
| 'reopen'
|
||||||
|
| 'cancel_release'
|
||||||
|
| 'cancel_deduct'
|
||||||
|
|
||||||
|
export type GrabWorkOrderFailureReason =
|
||||||
|
| 'worker_active_order_limit'
|
||||||
|
| 'worker_deposit_insufficient'
|
||||||
|
| 'work_order_not_open'
|
||||||
|
|
||||||
|
export type GrabWorkOrderResult = {
|
||||||
|
order: WorkOrderRow | null
|
||||||
|
failureReason: GrabWorkOrderFailureReason | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListInput = {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: string
|
||||||
|
keyword?: string
|
||||||
|
workerId?: number
|
||||||
|
categoryId?: number
|
||||||
|
workerSharingId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductRuleListInput = {
|
||||||
|
enabled?: boolean | null
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WalletLedgerListInput = {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
workerId?: number
|
||||||
|
ledgerType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinanceRequestListInput = {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
workerId?: number
|
||||||
|
status?: string
|
||||||
|
requestType?: string
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateWorkerInput = {
|
||||||
|
username: string
|
||||||
|
passwordHash: string
|
||||||
|
displayName: string
|
||||||
|
phone: string
|
||||||
|
levelId: number | null
|
||||||
|
status: string
|
||||||
|
inviterId?: number | null
|
||||||
|
inviteCode: string
|
||||||
|
now: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateWorkOrderInput = {
|
||||||
|
workOrderNo: string
|
||||||
|
orderId?: number | null
|
||||||
|
orderItemId?: number | null
|
||||||
|
taskId?: number | null
|
||||||
|
platformOrderId: string
|
||||||
|
productName: string
|
||||||
|
categoryId?: number | null
|
||||||
|
status: string
|
||||||
|
rewardAmount: number
|
||||||
|
requiredDepositAmount: number
|
||||||
|
depositThresholdAmount: number
|
||||||
|
sharingEnabled?: boolean
|
||||||
|
sharingTotalQuantity?: number
|
||||||
|
sharingUnitReward?: number
|
||||||
|
materialJson: string
|
||||||
|
requirementJson: string
|
||||||
|
now: string
|
||||||
|
}
|
||||||
+4
-1075
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,794 @@
|
|||||||
|
import { query, withTransaction } from '../../db/client.js'
|
||||||
|
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
|
||||||
|
import type { CreateWorkerInput, FinanceRequestListInput, ListInput, WalletLedgerListInput, WorkerFinanceRequestRow, WorkerLevelRow, WorkerUserRow, WorkerWalletLedgerRow, WorkerWalletRow } from './types.js'
|
||||||
|
import type { PoolClient } from 'pg'
|
||||||
|
|
||||||
|
|
||||||
|
const WORKER_USER_SELECT = `
|
||||||
|
SELECT
|
||||||
|
wu.*,
|
||||||
|
wl.level_key,
|
||||||
|
wl.name AS level_name,
|
||||||
|
wl.permission_json AS level_permission_json,
|
||||||
|
inv.username AS inviter_username,
|
||||||
|
inv.display_name AS inviter_display_name,
|
||||||
|
ww.available_amount,
|
||||||
|
ww.frozen_deposit_amount,
|
||||||
|
ww.total_credited_amount,
|
||||||
|
ww.total_settled_amount
|
||||||
|
FROM worker_users wu
|
||||||
|
LEFT JOIN worker_levels wl ON wl.id = wu.level_id
|
||||||
|
LEFT JOIN worker_users inv ON inv.id = wu.inviter_id
|
||||||
|
LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id
|
||||||
|
`
|
||||||
|
|
||||||
|
const WORKER_FINANCE_REQUEST_SELECT = `
|
||||||
|
SELECT
|
||||||
|
wfr.*,
|
||||||
|
wu.username AS worker_username,
|
||||||
|
wu.display_name AS worker_display_name,
|
||||||
|
wu.phone AS worker_phone
|
||||||
|
FROM worker_finance_requests wfr
|
||||||
|
LEFT JOIN worker_users wu ON wu.id = wfr.worker_id
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function getWorkerLevelByKey(levelKey: string): Promise<WorkerLevelRow | null> {
|
||||||
|
const result = await query<WorkerLevelRow>(
|
||||||
|
'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1',
|
||||||
|
[String(levelKey || '').trim()],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerLevelById(levelId: number | string): Promise<WorkerLevelRow | null> {
|
||||||
|
const result = await query<WorkerLevelRow>('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [
|
||||||
|
Number(levelId),
|
||||||
|
])
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkerLevels(): Promise<WorkerLevelRow[]> {
|
||||||
|
const result = await query<WorkerLevelRow>(
|
||||||
|
'SELECT * FROM worker_levels ORDER BY sort_order ASC, id ASC',
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertWorkerLevel(input: {
|
||||||
|
levelKey: string
|
||||||
|
name: string
|
||||||
|
sortOrder: number
|
||||||
|
status: string
|
||||||
|
permissionJson: string
|
||||||
|
now: string
|
||||||
|
}): Promise<WorkerLevelRow | null> {
|
||||||
|
const result = await query<WorkerLevelRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_levels (
|
||||||
|
level_key, name, sort_order, status, permission_json, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
||||||
|
ON CONFLICT (level_key) DO UPDATE
|
||||||
|
SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
permission_json = EXCLUDED.permission_json,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.levelKey,
|
||||||
|
input.name,
|
||||||
|
input.sortOrder,
|
||||||
|
input.status,
|
||||||
|
input.permissionJson,
|
||||||
|
input.now,
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countWorkerLevelUsages(levelId: number | string): Promise<number> {
|
||||||
|
const result = await query<{ total: number }>(
|
||||||
|
'SELECT COUNT(*)::int AS total FROM worker_users WHERE level_id = $1',
|
||||||
|
[Number(levelId)],
|
||||||
|
)
|
||||||
|
return Number(result.rows[0]?.total || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkerLevel(levelId: number | string): Promise<{ deleted: boolean }> {
|
||||||
|
const result = await query<{ id: number }>(
|
||||||
|
'DELETE FROM worker_levels WHERE id = $1 RETURNING id',
|
||||||
|
[Number(levelId)],
|
||||||
|
)
|
||||||
|
return { deleted: Boolean(result.rows[0]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerUserByUsername(username: string): Promise<WorkerUserRow | null> {
|
||||||
|
const result = await query<WorkerUserRow>(
|
||||||
|
`${WORKER_USER_SELECT} WHERE wu.username = $1 LIMIT 1`,
|
||||||
|
[
|
||||||
|
String(username || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerUserByInviteCode(
|
||||||
|
inviteCode: string,
|
||||||
|
): Promise<WorkerUserRow | null> {
|
||||||
|
const result = await query<WorkerUserRow>(
|
||||||
|
`${WORKER_USER_SELECT} WHERE wu.invite_code = $1 LIMIT 1`,
|
||||||
|
[String(inviteCode || '').trim().toUpperCase()],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerUserById(workerId: number | string): Promise<WorkerUserRow | null> {
|
||||||
|
const result = await query<WorkerUserRow>(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [
|
||||||
|
Number(workerId),
|
||||||
|
])
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkerUser(input: CreateWorkerInput): Promise<WorkerUserRow | null> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const result = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_users (
|
||||||
|
username, password_hash, display_name, phone, level_id, status,
|
||||||
|
inviter_id, invite_code,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.username,
|
||||||
|
input.passwordHash,
|
||||||
|
input.displayName,
|
||||||
|
input.phone,
|
||||||
|
input.levelId,
|
||||||
|
input.status,
|
||||||
|
input.inviterId || null,
|
||||||
|
input.inviteCode,
|
||||||
|
input.now,
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const workerId = Number(result.rows[0]?.id || 0)
|
||||||
|
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||||
|
return getWorkerUserByIdWithClient(client, workerId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkerUser(
|
||||||
|
workerId: number | string,
|
||||||
|
patch: Partial<
|
||||||
|
Pick<
|
||||||
|
WorkerUserRow,
|
||||||
|
'status' | 'review_note' | 'level_id' | 'session_version' | 'updated_at' | 'reviewed_at'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
): Promise<WorkerUserRow | null> {
|
||||||
|
const current = await getWorkerUserById(workerId)
|
||||||
|
if (!current) return null
|
||||||
|
const next = { ...current, ...patch }
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
UPDATE worker_users
|
||||||
|
SET
|
||||||
|
status = $1,
|
||||||
|
review_note = $2,
|
||||||
|
level_id = $3,
|
||||||
|
session_version = $4,
|
||||||
|
reviewed_at = $5,
|
||||||
|
updated_at = $6
|
||||||
|
WHERE id = $7
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
next.status,
|
||||||
|
next.review_note || '',
|
||||||
|
next.level_id || null,
|
||||||
|
Number(next.session_version || 1),
|
||||||
|
next.reviewed_at || null,
|
||||||
|
next.updated_at,
|
||||||
|
Number(workerId),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return getWorkerUserById(workerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkerPassword(input: {
|
||||||
|
workerId: number
|
||||||
|
passwordHash: string
|
||||||
|
sessionVersion: number
|
||||||
|
now: string
|
||||||
|
}): Promise<WorkerUserRow | null> {
|
||||||
|
const result = await query<{ id: number }>(
|
||||||
|
`
|
||||||
|
UPDATE worker_users
|
||||||
|
SET
|
||||||
|
password_hash = $1,
|
||||||
|
session_version = $2,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[input.passwordHash, input.sessionVersion, input.now, input.workerId],
|
||||||
|
)
|
||||||
|
if (!result.rows[0]) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return getWorkerUserById(input.workerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementWorkerSessionVersion(
|
||||||
|
workerId: number | string,
|
||||||
|
now: string,
|
||||||
|
): Promise<WorkerUserRow | null> {
|
||||||
|
const result = await query<{ id: number }>(
|
||||||
|
`
|
||||||
|
UPDATE worker_users
|
||||||
|
SET session_version = session_version + 1, updated_at = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[Number(workerId), now],
|
||||||
|
)
|
||||||
|
if (!result.rows[0]) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return getWorkerUserById(workerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkerUsers({
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
status = '',
|
||||||
|
keyword = '',
|
||||||
|
}: ListInput = {}): Promise<{ items: WorkerUserRow[]; total: number }> {
|
||||||
|
const { whereClause, params } = buildWorkerUserWhere({ status, keyword })
|
||||||
|
const totalResult = await query<{ total: number }>(
|
||||||
|
`SELECT COUNT(*)::int AS total FROM worker_users wu ${whereClause}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
params.push(pageSize, offset)
|
||||||
|
const itemsResult = await query<WorkerUserRow>(
|
||||||
|
`${WORKER_USER_SELECT}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY wu.id DESC
|
||||||
|
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureWorkerWallet(
|
||||||
|
workerId: number | string,
|
||||||
|
now: string,
|
||||||
|
): Promise<WorkerWalletRow | null> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
await ensureWorkerWalletWithClient(client, Number(workerId), now)
|
||||||
|
return getWorkerWalletWithClient(client, Number(workerId))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerWallet(workerId: number | string): Promise<WorkerWalletRow | null> {
|
||||||
|
const result = await query<WorkerWalletRow>(
|
||||||
|
'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1',
|
||||||
|
[Number(workerId)],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkerWalletLedgers({
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
workerId = 0,
|
||||||
|
ledgerType = '',
|
||||||
|
}: WalletLedgerListInput = {}): Promise<{ items: WorkerWalletLedgerRow[]; total: number }> {
|
||||||
|
const { whereClause, params } = buildWorkerWalletLedgerWhere({
|
||||||
|
workerId,
|
||||||
|
ledgerType,
|
||||||
|
})
|
||||||
|
const totalResult = await query<{ total: number }>(
|
||||||
|
`SELECT COUNT(*)::int AS total FROM worker_wallet_ledgers wwl ${whereClause}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
params.push(pageSize, offset)
|
||||||
|
const itemsResult = await query<WorkerWalletLedgerRow>(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM worker_wallet_ledgers wwl
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY wwl.created_at DESC, wwl.id DESC
|
||||||
|
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
items: itemsResult.rows,
|
||||||
|
total: Number(totalResult.rows[0]?.total || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addWorkerWalletCredit(input: {
|
||||||
|
workerId: number
|
||||||
|
amount: number
|
||||||
|
note: string
|
||||||
|
payloadJson: string
|
||||||
|
now: string
|
||||||
|
}): Promise<WorkerWalletRow | null> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||||
|
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
|
const nextAvailable = Number(wallet?.available_amount || 0) + input.amount
|
||||||
|
const nextFrozen = Number(wallet?.frozen_deposit_amount || 0)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE worker_wallets
|
||||||
|
SET
|
||||||
|
available_amount = $1,
|
||||||
|
total_credited_amount = total_credited_amount + $2,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE worker_id = $4
|
||||||
|
`,
|
||||||
|
[nextAvailable, input.amount, input.now, input.workerId],
|
||||||
|
)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_wallet_ledgers (
|
||||||
|
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||||
|
audit_status, note, payload_json, created_at
|
||||||
|
) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.workerId,
|
||||||
|
input.amount,
|
||||||
|
nextAvailable,
|
||||||
|
nextFrozen,
|
||||||
|
input.note,
|
||||||
|
input.payloadJson,
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return getWorkerWalletWithClient(client, input.workerId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkerFinanceRequest(input: {
|
||||||
|
workerId: number
|
||||||
|
requestType: string
|
||||||
|
amount: number
|
||||||
|
accountChannel: string
|
||||||
|
accountName: string
|
||||||
|
accountNo: string
|
||||||
|
note: string
|
||||||
|
payloadJson: string
|
||||||
|
now: string
|
||||||
|
}): Promise<WorkerFinanceRequestRow | null> {
|
||||||
|
const result = await query<WorkerFinanceRequestRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_finance_requests (
|
||||||
|
worker_id, request_type, status, amount,
|
||||||
|
account_channel, account_name, account_no,
|
||||||
|
note, reviewed_note, payload_json, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7, '', $8::jsonb, $9, $10)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.workerId,
|
||||||
|
input.requestType,
|
||||||
|
input.amount,
|
||||||
|
input.accountChannel,
|
||||||
|
input.accountName,
|
||||||
|
input.accountNo,
|
||||||
|
input.note,
|
||||||
|
input.payloadJson,
|
||||||
|
input.now,
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerFinanceRequestById(
|
||||||
|
requestId: number | string,
|
||||||
|
): Promise<WorkerFinanceRequestRow | null> {
|
||||||
|
const result = await query<WorkerFinanceRequestRow>(
|
||||||
|
`${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`,
|
||||||
|
[Number(requestId)],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkerFinanceRequests({
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
workerId = 0,
|
||||||
|
status = '',
|
||||||
|
requestType = '',
|
||||||
|
keyword = '',
|
||||||
|
}: FinanceRequestListInput = {}): Promise<{ items: WorkerFinanceRequestRow[]; total: number }> {
|
||||||
|
const { whereClause, params } = buildWorkerFinanceRequestWhere({
|
||||||
|
workerId,
|
||||||
|
status,
|
||||||
|
requestType,
|
||||||
|
keyword,
|
||||||
|
})
|
||||||
|
const totalResult = await query<{ total: number }>(
|
||||||
|
`SELECT COUNT(*)::int AS total FROM worker_finance_requests wfr
|
||||||
|
LEFT JOIN worker_users wu ON wu.id = wfr.worker_id
|
||||||
|
${whereClause}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
params.push(pageSize, offset)
|
||||||
|
const itemsResult = await query<WorkerFinanceRequestRow>(
|
||||||
|
`${WORKER_FINANCE_REQUEST_SELECT}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY wfr.created_at DESC, wfr.id DESC
|
||||||
|
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
items: itemsResult.rows,
|
||||||
|
total: Number(totalResult.rows[0]?.total || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reviewWorkerFinanceRequest(input: {
|
||||||
|
requestId: number
|
||||||
|
status: string
|
||||||
|
reviewedNote: string
|
||||||
|
now: string
|
||||||
|
}): Promise<{
|
||||||
|
request: WorkerFinanceRequestRow | null
|
||||||
|
failureReason: 'request_not_pending' | 'withdraw_insufficient' | null
|
||||||
|
}> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const currentResult = await client.query<WorkerFinanceRequestRow>(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM worker_finance_requests
|
||||||
|
WHERE id = $1
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[input.requestId],
|
||||||
|
)
|
||||||
|
const current = currentResult.rows[0] || null
|
||||||
|
if (!current) {
|
||||||
|
return {
|
||||||
|
request: null,
|
||||||
|
failureReason: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.status !== 'pending') {
|
||||||
|
return {
|
||||||
|
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
|
||||||
|
failureReason: 'request_not_pending',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.status === 'approved') {
|
||||||
|
await ensureWorkerWalletWithClient(client, Number(current.worker_id), input.now)
|
||||||
|
const wallet = await getWorkerWalletWithClient(client, Number(current.worker_id))
|
||||||
|
const availableAmount = Number(wallet?.available_amount || 0)
|
||||||
|
const frozenAmount = Number(wallet?.frozen_deposit_amount || 0)
|
||||||
|
const amount = Number(current.amount || 0)
|
||||||
|
const note =
|
||||||
|
input.reviewedNote ||
|
||||||
|
(current.request_type === 'withdraw' ? '提现申请审核通过' : '充值申请审核通过')
|
||||||
|
|
||||||
|
if (current.request_type === 'withdraw') {
|
||||||
|
if (availableAmount < amount) {
|
||||||
|
return {
|
||||||
|
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
|
||||||
|
failureReason: 'withdraw_insufficient',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAvailable = availableAmount - amount
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE worker_wallets
|
||||||
|
SET available_amount = $1, updated_at = $2
|
||||||
|
WHERE worker_id = $3
|
||||||
|
`,
|
||||||
|
[nextAvailable, input.now, Number(current.worker_id)],
|
||||||
|
)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_wallet_ledgers (
|
||||||
|
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||||
|
audit_status, note, payload_json, created_at
|
||||||
|
) VALUES ($1, 'withdraw_paid', $2, $3, $4, 'approved', $5, $6::jsonb, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
Number(current.worker_id),
|
||||||
|
-amount,
|
||||||
|
nextAvailable,
|
||||||
|
frozenAmount,
|
||||||
|
note,
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: Number(current.id),
|
||||||
|
requestType: current.request_type,
|
||||||
|
source: 'admin_finance_review',
|
||||||
|
}),
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const nextAvailable = availableAmount + amount
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE worker_wallets
|
||||||
|
SET
|
||||||
|
available_amount = $1,
|
||||||
|
total_credited_amount = total_credited_amount + $2,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE worker_id = $4
|
||||||
|
`,
|
||||||
|
[nextAvailable, amount, input.now, Number(current.worker_id)],
|
||||||
|
)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_wallet_ledgers (
|
||||||
|
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||||
|
audit_status, note, payload_json, created_at
|
||||||
|
) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
Number(current.worker_id),
|
||||||
|
amount,
|
||||||
|
nextAvailable,
|
||||||
|
frozenAmount,
|
||||||
|
note,
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: Number(current.id),
|
||||||
|
requestType: current.request_type,
|
||||||
|
source: 'admin_finance_review',
|
||||||
|
}),
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE worker_finance_requests
|
||||||
|
SET
|
||||||
|
status = $1,
|
||||||
|
reviewed_note = $2,
|
||||||
|
reviewed_at = $3,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE id = $4
|
||||||
|
`,
|
||||||
|
[input.status, input.reviewedNote, input.now, input.requestId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
|
||||||
|
failureReason: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerFinanceRequestSummary(workerId: number | string) {
|
||||||
|
const result = await query<{
|
||||||
|
pending_withdraw_amount: number
|
||||||
|
approved_withdraw_amount: number
|
||||||
|
pending_recharge_amount: number
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
SUM(CASE WHEN request_type = 'withdraw' AND status = 'pending' THEN amount ELSE 0 END),
|
||||||
|
0
|
||||||
|
)::int AS pending_withdraw_amount,
|
||||||
|
COALESCE(
|
||||||
|
SUM(CASE WHEN request_type = 'withdraw' AND status = 'approved' THEN amount ELSE 0 END),
|
||||||
|
0
|
||||||
|
)::int AS approved_withdraw_amount,
|
||||||
|
COALESCE(
|
||||||
|
SUM(CASE WHEN request_type = 'recharge' AND status = 'pending' THEN amount ELSE 0 END),
|
||||||
|
0
|
||||||
|
)::int AS pending_recharge_amount
|
||||||
|
FROM worker_finance_requests
|
||||||
|
WHERE worker_id = $1
|
||||||
|
`,
|
||||||
|
[Number(workerId)],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
pendingWithdrawAmount: Number(result.rows[0]?.pending_withdraw_amount || 0),
|
||||||
|
approvedWithdrawAmount: Number(result.rows[0]?.approved_withdraw_amount || 0),
|
||||||
|
pendingRechargeAmount: Number(result.rows[0]?.pending_recharge_amount || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countWorkerAcceptedOrders(workerId: number | string): Promise<number> {
|
||||||
|
const result = await query<{ total: number }>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted')
|
||||||
|
+
|
||||||
|
COALESCE(
|
||||||
|
(SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'),
|
||||||
|
0
|
||||||
|
) AS total
|
||||||
|
`,
|
||||||
|
[Number(workerId)],
|
||||||
|
)
|
||||||
|
return Number(result.rows[0]?.total || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function maybeUpgradeWorkerLevelWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
workerId: number,
|
||||||
|
now: string,
|
||||||
|
): Promise<WorkerUserRow | null> {
|
||||||
|
const acceptedResult = await client.query<{ total: number }>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted')
|
||||||
|
+
|
||||||
|
COALESCE(
|
||||||
|
(SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'),
|
||||||
|
0
|
||||||
|
) AS total
|
||||||
|
`,
|
||||||
|
[workerId],
|
||||||
|
)
|
||||||
|
const acceptedCount = Number(acceptedResult.rows[0]?.total || 0)
|
||||||
|
|
||||||
|
const nextLevelResult = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM worker_levels
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND COALESCE((permission_json->>'upgradeThreshold')::int, 0) <= $1
|
||||||
|
ORDER BY COALESCE((permission_json->>'upgradeThreshold')::int, 0) DESC, sort_order ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[acceptedCount],
|
||||||
|
)
|
||||||
|
const nextLevelId = Number(nextLevelResult.rows[0]?.id || 0)
|
||||||
|
if (!nextLevelId) return null
|
||||||
|
|
||||||
|
const current = await client.query<{ id: number; level_id: number | null }>(
|
||||||
|
'SELECT id, level_id FROM worker_users WHERE id = $1 LIMIT 1',
|
||||||
|
[workerId],
|
||||||
|
)
|
||||||
|
const currentRow = current.rows[0]
|
||||||
|
if (!currentRow || Number(currentRow.level_id || 0) === nextLevelId) return null
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'UPDATE worker_users SET level_id = $1, updated_at = $2 WHERE id = $3',
|
||||||
|
[nextLevelId, now, workerId],
|
||||||
|
)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_level_logs (
|
||||||
|
worker_id, from_level_id, to_level_id, reason, payload_json, created_at
|
||||||
|
) VALUES ($1, $2, $3, 'auto', $4::jsonb, $5)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
workerId,
|
||||||
|
currentRow.level_id || null,
|
||||||
|
nextLevelId,
|
||||||
|
JSON.stringify({ acceptedCount }),
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return getWorkerUserByIdWithClient(client, workerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorkerUserByIdWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
workerId: number | string,
|
||||||
|
): Promise<WorkerUserRow | null> {
|
||||||
|
const result = await client.query<WorkerUserRow>(
|
||||||
|
`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`,
|
||||||
|
[Number(workerId)],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorkerFinanceRequestByIdWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
requestId: number | string,
|
||||||
|
): Promise<WorkerFinanceRequestRow | null> {
|
||||||
|
const result = await client.query<WorkerFinanceRequestRow>(
|
||||||
|
`${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`,
|
||||||
|
[Number(requestId)],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkerUserWhere({
|
||||||
|
status = '',
|
||||||
|
keyword = '',
|
||||||
|
}: Pick<ListInput, 'status' | 'keyword'>) {
|
||||||
|
const filters: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
if (status) {
|
||||||
|
params.push(status)
|
||||||
|
filters.push(`wu.status = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
params.push(`%${keyword}%`)
|
||||||
|
filters.push(
|
||||||
|
`(wu.username ILIKE $${params.length} OR wu.display_name ILIKE $${params.length} OR wu.phone ILIKE $${params.length})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkerWalletLedgerWhere({
|
||||||
|
workerId = 0,
|
||||||
|
ledgerType = '',
|
||||||
|
}: WalletLedgerListInput) {
|
||||||
|
const filters: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
if (workerId) {
|
||||||
|
params.push(workerId)
|
||||||
|
filters.push(`wwl.worker_id = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (ledgerType) {
|
||||||
|
params.push(ledgerType)
|
||||||
|
filters.push(`wwl.ledger_type = $${params.length}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkerFinanceRequestWhere({
|
||||||
|
workerId = 0,
|
||||||
|
status = '',
|
||||||
|
requestType = '',
|
||||||
|
keyword = '',
|
||||||
|
}: FinanceRequestListInput) {
|
||||||
|
const filters: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
if (workerId) {
|
||||||
|
params.push(workerId)
|
||||||
|
filters.push(`wfr.worker_id = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
params.push(status)
|
||||||
|
filters.push(`wfr.status = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (requestType) {
|
||||||
|
params.push(requestType)
|
||||||
|
filters.push(`wfr.request_type = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
params.push(`%${keyword}%`)
|
||||||
|
filters.push(
|
||||||
|
`(wu.username ILIKE $${params.length}
|
||||||
|
OR wu.display_name ILIKE $${params.length}
|
||||||
|
OR wu.phone ILIKE $${params.length}
|
||||||
|
OR wfr.account_name ILIKE $${params.length}
|
||||||
|
OR wfr.account_no ILIKE $${params.length})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
syncAdminWorkerOrdersFromSource,
|
syncAdminWorkerOrdersFromSource,
|
||||||
unpublishAdminWorkOrder,
|
unpublishAdminWorkOrder,
|
||||||
updateAdminWorkOrderSharing,
|
updateAdminWorkOrderSharing,
|
||||||
} from '../../services/worker-platform/worker-platform-service.js'
|
} from '../../services/worker-platform/index.js'
|
||||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middlewa
|
|||||||
import {
|
import {
|
||||||
collectLookupWorkOrder,
|
collectLookupWorkOrder,
|
||||||
collectSubmitWorkOrder,
|
collectSubmitWorkOrder,
|
||||||
} from '../services/worker-platform/worker-platform-service.js'
|
} from '../services/worker-platform/index.js'
|
||||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
logoutWorkerSession,
|
logoutWorkerSession,
|
||||||
registerWorker,
|
registerWorker,
|
||||||
submitWorkerOrderAcceptance,
|
submitWorkerOrderAcceptance,
|
||||||
} from '../services/worker-platform/worker-platform-service.js'
|
} from '../services/worker-platform/index.js'
|
||||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||||
import {
|
import {
|
||||||
extractOptionalWorkerBearerToken,
|
extractOptionalWorkerBearerToken,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
requireActiveWorkerSession,
|
requireActiveWorkerSession,
|
||||||
verifyWorkerSessionToken,
|
verifyWorkerSessionToken,
|
||||||
type WorkerSession,
|
type WorkerSession,
|
||||||
} from '../../services/worker-platform/worker-platform-service.js'
|
} from '../../services/worker-platform/index.js'
|
||||||
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
||||||
|
|
||||||
export async function requireWorkerSession(req: Request, res: Response, next: NextFunction): Promise<void> {
|
export async function requireWorkerSession(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
resolveOrderFulfillmentReadiness,
|
resolveOrderFulfillmentReadiness,
|
||||||
syncOrderFulfillmentAttachments,
|
syncOrderFulfillmentAttachments,
|
||||||
} from '../fulfillment/order-fulfillment-readiness-service.js'
|
} from '../fulfillment/order-fulfillment-readiness-service.js'
|
||||||
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/worker-platform-service.js'
|
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/index.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { logIntegration } from '../../utils/logger.js'
|
import { logIntegration } from '../../utils/logger.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '../../../repositories/order-item-repo.js'
|
} from '../../../repositories/order-item-repo.js'
|
||||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||||
import { upsertOrderFromSource } from '../../order/order-service.js'
|
import { upsertOrderFromSource } from '../../order/order-service.js'
|
||||||
import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/worker-platform-service.js'
|
import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/index.js'
|
||||||
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
|
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
|
||||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
|
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
|
||||||
|
|||||||
@@ -0,0 +1,955 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
|
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||||
|
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||||
|
import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
|
||||||
|
import {
|
||||||
|
acceptWorkOrderAndSettle,
|
||||||
|
addWorkerWalletCredit,
|
||||||
|
countWorkerAcceptedOrders,
|
||||||
|
countWorkerActiveOrders,
|
||||||
|
countWorkCategoryUsages,
|
||||||
|
countWorkerLevelUsages,
|
||||||
|
countWorkOrderPendingSharingSubmissions,
|
||||||
|
cancelWorkerWorkOrder,
|
||||||
|
countWorkerCancellationsSince,
|
||||||
|
createWorkOrder,
|
||||||
|
createWorkOrderEvent,
|
||||||
|
createWorkerFinanceRequest,
|
||||||
|
createWorkerUser,
|
||||||
|
deleteWorkCategory,
|
||||||
|
deleteWorkerLevel,
|
||||||
|
getWorkerFinanceRequestById,
|
||||||
|
getWorkerFinanceRequestSummary,
|
||||||
|
getWorkCategoryById,
|
||||||
|
getWorkCategoryByKey,
|
||||||
|
getWorkerLevelById,
|
||||||
|
getWorkerLevelByKey,
|
||||||
|
getWorkerUserByInviteCode,
|
||||||
|
getWorkerUserById,
|
||||||
|
getWorkerUserByUsername,
|
||||||
|
getWorkOrderById,
|
||||||
|
getWorkOrderByOrderItemId,
|
||||||
|
getWorkOrderShare,
|
||||||
|
grabWorkOrder,
|
||||||
|
incrementWorkerSessionVersion,
|
||||||
|
joinWorkOrderShare,
|
||||||
|
listAllWorkCategories,
|
||||||
|
listPendingMaterialWorkOrdersByPlatformOrderId,
|
||||||
|
listWorkerFinanceRequests,
|
||||||
|
listWorkerSharesByWorker,
|
||||||
|
listWorkerWalletLedgers,
|
||||||
|
listWorkCategories,
|
||||||
|
listWorkOrders,
|
||||||
|
listWorkProductRules,
|
||||||
|
listWorkerLevels,
|
||||||
|
listWorkerUsers,
|
||||||
|
listWorkOrderShares,
|
||||||
|
listWorkOrderSharesByOrderIds,
|
||||||
|
resolveProblemWorkOrder,
|
||||||
|
reviewWorkerFinanceRequest,
|
||||||
|
submitWorkOrderShareAcceptance,
|
||||||
|
updateWorkOrder,
|
||||||
|
updateWorkerPassword,
|
||||||
|
updateWorkerUser,
|
||||||
|
upsertWorkCategory,
|
||||||
|
upsertWorkProductRule,
|
||||||
|
upsertWorkerLevel,
|
||||||
|
type WorkCategoryRow,
|
||||||
|
type WorkerFinanceRequestRow,
|
||||||
|
type WorkOrderRow,
|
||||||
|
type WorkProductRuleRow,
|
||||||
|
type WorkerLevelRow,
|
||||||
|
type WorkOrderShareRow,
|
||||||
|
type WorkerWalletLedgerRow,
|
||||||
|
type WorkerUserRow,
|
||||||
|
type GrabWorkOrderFailureReason,
|
||||||
|
type ProblemWorkOrderResolutionAction,
|
||||||
|
} from '../../repositories/worker-platform/index.js'
|
||||||
|
import type { JsonObject } from '../../types/json.js'
|
||||||
|
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||||
|
import { createHttpError } from '../../utils/http.js'
|
||||||
|
import { randomId } from '../../utils/random.js'
|
||||||
|
import { nowIso, addHours } from '../../utils/time.js'
|
||||||
|
import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js'
|
||||||
|
import {
|
||||||
|
normalizeStoredFileUrl,
|
||||||
|
refreshStoredFileUrl,
|
||||||
|
refreshUploadedFileUrls,
|
||||||
|
} from '../file-storage/file-storage-service.js'
|
||||||
|
import {
|
||||||
|
getWorkerFinanceConfig,
|
||||||
|
saveWorkerFinanceConfig,
|
||||||
|
} from './worker-finance-config-service.js'
|
||||||
|
|
||||||
|
import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js'
|
||||||
|
import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker } from './worker-service.js'
|
||||||
|
|
||||||
|
export async function listAdminWorkerLevels() {
|
||||||
|
await ensureWorkerPlatformDefaults()
|
||||||
|
return { items: (await listWorkerLevels()).map(mapWorkerLevel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkCategories() {
|
||||||
|
await ensureWorkerPlatformDefaults()
|
||||||
|
return { items: (await listAllWorkCategories()).map(mapWorkCategory) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAdminWorkCategory(payload: JsonObject = {}) {
|
||||||
|
const now = nowIso()
|
||||||
|
const categoryKey = normalizeSlugKey(
|
||||||
|
payload.categoryKey || payload.category_key || payload.name,
|
||||||
|
DEFAULT_CATEGORY_KEY,
|
||||||
|
)
|
||||||
|
const name = String(payload.name || '').trim() || '默认分类'
|
||||||
|
const category = await upsertWorkCategory({
|
||||||
|
categoryKey,
|
||||||
|
name,
|
||||||
|
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||||
|
status: normalizeEnabledStatus(payload.status),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
return { category: mapWorkCategory(category) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminWorkCategory(categoryId: number | string) {
|
||||||
|
const category = await getWorkCategoryById(Number(categoryId))
|
||||||
|
if (!category) {
|
||||||
|
throw createHttpError('分类不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'work_category_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const usages = await countWorkCategoryUsages(Number(categoryId))
|
||||||
|
if (usages.workOrderCount > 0 || usages.productRuleCount > 0) {
|
||||||
|
throw createHttpError(
|
||||||
|
`该分类已被 ${usages.workOrderCount} 个工单和 ${usages.productRuleCount} 条物品规则使用,无法删除,请先停用`,
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_category_in_use',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const { deleted } = await deleteWorkCategory(Number(categoryId))
|
||||||
|
return { deleted }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
||||||
|
const now = nowIso()
|
||||||
|
const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY
|
||||||
|
const name = String(payload.name || '').trim() || DEFAULT_LEVEL_NAME
|
||||||
|
const permissions = {
|
||||||
|
depositFreeAmount: normalizeAmountFen(
|
||||||
|
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 1),
|
||||||
|
upgradeThreshold: normalizeInteger(payload.upgradeThreshold, 0),
|
||||||
|
}
|
||||||
|
const level = await upsertWorkerLevel({
|
||||||
|
levelKey,
|
||||||
|
name,
|
||||||
|
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||||
|
status: String(payload.status || 'active').trim() === 'disabled' ? 'disabled' : 'active',
|
||||||
|
permissionJson: JSON.stringify(permissions),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
return { level: mapWorkerLevel(level) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminWorkerLevel(levelId: number | string) {
|
||||||
|
const level = await getWorkerLevelById(Number(levelId))
|
||||||
|
if (!level) {
|
||||||
|
throw createHttpError('等级不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'worker_level_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (level.level_key === DEFAULT_LEVEL_KEY) {
|
||||||
|
throw createHttpError('默认等级(VIP1)不可删除', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_level_default_protected',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const usageCount = await countWorkerLevelUsages(Number(levelId))
|
||||||
|
if (usageCount > 0) {
|
||||||
|
throw createHttpError(`该等级正在被 ${usageCount} 名打手使用,请先调整打手等级后再删除`, {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_level_in_use',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const { deleted } = await deleteWorkerLevel(Number(levelId))
|
||||||
|
return { deleted }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkProductRules(query: JsonObject = {}) {
|
||||||
|
await ensureWorkerPlatformDefaults()
|
||||||
|
const enabledValue = String(query.enabled ?? '').trim()
|
||||||
|
const enabled = enabledValue
|
||||||
|
? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase())
|
||||||
|
: null
|
||||||
|
const items = await listWorkProductRules({
|
||||||
|
enabled,
|
||||||
|
keyword: String(query.keyword || '').trim(),
|
||||||
|
})
|
||||||
|
return { items: items.map(mapWorkProductRule) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||||
|
const defaults = await ensureWorkerPlatformDefaults()
|
||||||
|
const productName = String(
|
||||||
|
payload.productName || payload.product_name || payload.skuName || '',
|
||||||
|
).trim()
|
||||||
|
const skuCode = String(payload.skuCode || payload.sku_code || '').trim()
|
||||||
|
if (!productName && !skuCode) {
|
||||||
|
throw createHttpError('请填写商品名或 SKU', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_product_rule_target_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
|
||||||
|
const sharingEnabled = normalizeBoolean(
|
||||||
|
payload.sharingEnabled ?? payload.sharing_enabled,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
const sharingUnitReward = normalizeAmountFen(
|
||||||
|
payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const sharingTotalAmount = normalizeAmountFen(
|
||||||
|
payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const sharingTotalQuantity = resolveSharingQuantity(
|
||||||
|
sharingTotalAmount,
|
||||||
|
normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1),
|
||||||
|
sharingUnitReward,
|
||||||
|
)
|
||||||
|
const resolvedSharingTotalAmount =
|
||||||
|
sharingTotalAmount > 0 ? sharingTotalAmount : sharingUnitReward * sharingTotalQuantity
|
||||||
|
if (sharingEnabled) {
|
||||||
|
if (resolvedSharingTotalAmount <= 0) {
|
||||||
|
throw createHttpError('启用拼单时请填写拼单总价或单价', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_product_rule_sharing_amount_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (rewardAmount <= 0) {
|
||||||
|
throw createHttpError('接单金额必须大于 0', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_product_rule_reward_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount
|
||||||
|
|
||||||
|
const depositThresholdAmount = normalizeAmountFen(
|
||||||
|
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
|
||||||
|
DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||||
|
)
|
||||||
|
const requiredDepositAmount =
|
||||||
|
payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined
|
||||||
|
? finalRewardAmount > depositThresholdAmount
|
||||||
|
? finalRewardAmount
|
||||||
|
: 0
|
||||||
|
: normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0)
|
||||||
|
const fields = normalizeRequirementFieldsFromPayload(payload)
|
||||||
|
const rule = await upsertWorkProductRule({
|
||||||
|
ruleKey: normalizeSlugKey(
|
||||||
|
payload.ruleKey || payload.rule_key || skuCode || productName,
|
||||||
|
`rule-${Date.now()}`,
|
||||||
|
),
|
||||||
|
provider: String(payload.provider || '').trim(),
|
||||||
|
platform: String(payload.platform || '').trim(),
|
||||||
|
shopId: String(payload.shopId || payload.shop_id || '').trim(),
|
||||||
|
skuCode,
|
||||||
|
productName,
|
||||||
|
matchType: normalizeMatchType(payload.matchType || payload.match_type),
|
||||||
|
categoryId:
|
||||||
|
normalizeOptionalId(payload.categoryId || payload.category_id) ||
|
||||||
|
defaults.category?.id ||
|
||||||
|
null,
|
||||||
|
enabled: normalizeBoolean(payload.enabled, true),
|
||||||
|
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
|
||||||
|
rewardAmount: finalRewardAmount,
|
||||||
|
requiredDepositAmount,
|
||||||
|
depositThresholdAmount,
|
||||||
|
sharingEnabled,
|
||||||
|
sharingTotalQuantity,
|
||||||
|
sharingUnitReward,
|
||||||
|
requirementJson: JSON.stringify({ fields }),
|
||||||
|
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
return { rule: mapWorkProductRule(rule) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkerUsers(query: JsonObject = {}) {
|
||||||
|
const page = normalizePage(query.page)
|
||||||
|
const pageSize = normalizePageSize(query.pageSize)
|
||||||
|
const { items, total } = await listWorkerUsers({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
status: String(query.status || '').trim(),
|
||||||
|
keyword: String(query.keyword || '').trim(),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
items: items.map(mapWorkerUser),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
|
||||||
|
const worker = await getRequiredWorker(workerId)
|
||||||
|
const status = normalizeReviewStatus(payload.status)
|
||||||
|
const requestedLevelId = normalizeOptionalId(payload.levelId)
|
||||||
|
let levelId = worker.level_id || null
|
||||||
|
if (requestedLevelId) {
|
||||||
|
const level = await getWorkerLevelById(requestedLevelId)
|
||||||
|
if (!level) {
|
||||||
|
throw createHttpError('接单等级不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'worker_level_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
levelId = Number(level.id)
|
||||||
|
}
|
||||||
|
if (!levelId) {
|
||||||
|
levelId = (await ensureWorkerPlatformDefaults()).level?.id || null
|
||||||
|
}
|
||||||
|
const now = nowIso()
|
||||||
|
const updated = await updateWorkerUser(worker.id, {
|
||||||
|
status,
|
||||||
|
level_id: levelId,
|
||||||
|
review_note: String(payload.reviewNote || payload.note || '').trim(),
|
||||||
|
session_version: normalizeSessionVersion(worker.session_version) + 1,
|
||||||
|
reviewed_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
return { worker: mapWorkerUser(updated || worker) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function creditAdminWorkerWallet(workerId: number | string, payload: JsonObject = {}) {
|
||||||
|
await getRequiredWorker(workerId)
|
||||||
|
const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0)
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw createHttpError('充值金额必须大于 0', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_credit_amount_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const wallet = await addWorkerWalletCredit({
|
||||||
|
workerId: Number(workerId),
|
||||||
|
amount,
|
||||||
|
note: String(payload.note || '后台人工充值').trim(),
|
||||||
|
payloadJson: JSON.stringify({ source: 'admin_manual_credit' }),
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
return { wallet: mapWallet(wallet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminWorkerFinanceConfig() {
|
||||||
|
return getWorkerFinanceConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAdminWorkerFinanceConfig(payload: JsonObject = {}) {
|
||||||
|
const config = await saveWorkerFinanceConfig(payload)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkerFinanceRequests(query: JsonObject = {}) {
|
||||||
|
const page = normalizePage(query.page)
|
||||||
|
const pageSize = normalizePageSize(query.pageSize)
|
||||||
|
const status = normalizeFinanceRequestStatus(query.status)
|
||||||
|
const requestType = normalizeFinanceRequestType(query.requestType)
|
||||||
|
const keyword = String(query.keyword || '').trim()
|
||||||
|
const { items, total } = await listWorkerFinanceRequests({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
status,
|
||||||
|
requestType,
|
||||||
|
keyword,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
items: items.map(mapFinanceRequest),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reviewAdminWorkerFinanceRequest(
|
||||||
|
requestId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
) {
|
||||||
|
const current = await getWorkerFinanceRequestById(requestId)
|
||||||
|
if (!current) {
|
||||||
|
throw createHttpError('资金申请不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'worker_finance_request_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = normalizeAdminFinanceReviewStatus(payload.status)
|
||||||
|
const reviewedNote = String(payload.reviewedNote || payload.note || '').trim()
|
||||||
|
const reviewed = await reviewWorkerFinanceRequest({
|
||||||
|
requestId: Number(current.id),
|
||||||
|
status,
|
||||||
|
reviewedNote,
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (reviewed.failureReason === 'request_not_pending') {
|
||||||
|
throw createHttpError('该申请已处理,请刷新后重试', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_finance_request_reviewed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewed.failureReason === 'withdraw_insufficient') {
|
||||||
|
throw createHttpError('打手余额不足,暂时无法通过该提现申请', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_finance_request_withdraw_insufficient',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reviewed.request) {
|
||||||
|
throw createHttpError('资金申请不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'worker_finance_request_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: mapFinanceRequest(reviewed.request),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||||
|
const page = normalizePage(query.page)
|
||||||
|
const pageSize = normalizePageSize(query.pageSize)
|
||||||
|
const { items, total } = await listWorkOrders({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
status: String(query.status || '').trim(),
|
||||||
|
keyword: String(query.keyword || '').trim(),
|
||||||
|
})
|
||||||
|
const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id)))
|
||||||
|
const sharesByOrderId = new Map<number, WorkOrderShareRow[]>()
|
||||||
|
for (const share of shares) {
|
||||||
|
const orderId = Number(share.work_order_id)
|
||||||
|
const bucket = sharesByOrderId.get(orderId) || []
|
||||||
|
bucket.push(share)
|
||||||
|
sharesByOrderId.set(orderId, bucket)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: items.map((item) => {
|
||||||
|
const orderShares = sharesByOrderId.get(Number(item.id)) || []
|
||||||
|
const joinedQuantity = orderShares
|
||||||
|
.filter((share) => share.status !== 'cancelled')
|
||||||
|
.reduce((sum, share) => sum + Number(share.quantity || 0), 0)
|
||||||
|
const pendingSubmissionCount = orderShares.filter(
|
||||||
|
(share) => share.status === 'joined',
|
||||||
|
).length
|
||||||
|
return {
|
||||||
|
...mapWorkOrderAdmin(item),
|
||||||
|
sharingProgress: {
|
||||||
|
joinedQuantity,
|
||||||
|
pendingSubmissionCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminWorkOrderSharing(workOrderId: number | string) {
|
||||||
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
|
const shares = await listWorkOrderShares(workOrder.id)
|
||||||
|
return {
|
||||||
|
order: mapWorkOrderAdmin(workOrder),
|
||||||
|
shares: shares.map(mapWorkOrderShare),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminWorkOrderSharing(
|
||||||
|
workOrderId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
) {
|
||||||
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
|
if (![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN].includes(workOrder.status as never)) {
|
||||||
|
throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_sharing_config_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const enabled = normalizeBoolean(
|
||||||
|
payload.enabled ?? payload.sharingEnabled ?? payload.sharing_enabled,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
const unitReward = normalizeAmountFen(
|
||||||
|
payload.unitReward ?? payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const totalAmount = normalizeAmountFen(
|
||||||
|
payload.totalAmount ?? payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const totalQuantity = resolveSharingQuantity(
|
||||||
|
totalAmount,
|
||||||
|
normalizePositiveInteger(
|
||||||
|
payload.totalQuantity ?? payload.sharingTotalQuantity ?? payload.sharing_total_quantity,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
unitReward,
|
||||||
|
)
|
||||||
|
const resolvedTotalAmount = totalAmount > 0 ? totalAmount : unitReward * totalQuantity
|
||||||
|
const now = nowIso()
|
||||||
|
const updated = await updateWorkOrder(workOrder.id, {
|
||||||
|
sharing_enabled: enabled,
|
||||||
|
sharing_total_quantity: totalQuantity,
|
||||||
|
sharing_unit_reward: unitReward,
|
||||||
|
reward_amount: enabled ? resolvedTotalAmount : workOrder.reward_amount,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
await createWorkOrderEvent({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: '',
|
||||||
|
eventType: 'sharing_config_updated',
|
||||||
|
fromStatus: workOrder.status,
|
||||||
|
toStatus: updated?.status || workOrder.status,
|
||||||
|
payloadJson: JSON.stringify({
|
||||||
|
enabled,
|
||||||
|
totalQuantity,
|
||||||
|
unitReward,
|
||||||
|
totalAmount: resolvedTotalAmount,
|
||||||
|
}),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSharingQuantity(
|
||||||
|
totalAmount: number,
|
||||||
|
fallbackQuantity: number,
|
||||||
|
unitReward: number,
|
||||||
|
): number {
|
||||||
|
if (totalAmount > 0 && unitReward > 0) {
|
||||||
|
return Math.max(1, Math.round(totalAmount / unitReward))
|
||||||
|
}
|
||||||
|
return Math.max(1, fallbackQuantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitAdminWorkOrderMaterial(
|
||||||
|
workOrderId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
) {
|
||||||
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
|
if (
|
||||||
|
![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED].includes(
|
||||||
|
workOrder.status as never,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw createHttpError('当前状态不能补充资料', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_material_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = resolveRequirementFields(workOrder)
|
||||||
|
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||||
|
const complete = fields.every(
|
||||||
|
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||||
|
)
|
||||||
|
const now = nowIso()
|
||||||
|
const material = {
|
||||||
|
...safeParseJson(workOrder.material_json),
|
||||||
|
collect: {
|
||||||
|
fields: submittedFields,
|
||||||
|
submittedAt: now,
|
||||||
|
complete,
|
||||||
|
source: 'admin',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL
|
||||||
|
const updated = await updateWorkOrder(workOrder.id, {
|
||||||
|
status: nextStatus,
|
||||||
|
material_json: material,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
await createWorkOrderEvent({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: '',
|
||||||
|
eventType: 'material_completed',
|
||||||
|
fromStatus: workOrder.status,
|
||||||
|
toStatus: nextStatus,
|
||||||
|
payloadJson: JSON.stringify({ fields: submittedFields, complete }),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
return { order: mapWorkOrderAdmin(updated || workOrder), complete }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAdminWorkerOrdersFromSource(payload: JsonObject = {}) {
|
||||||
|
const order = await resolveSourceOrder(payload)
|
||||||
|
const orderItems = await listOrderItemsByOrderId(order.id)
|
||||||
|
const result = await syncWorkerOrdersForSourceOrder(order, orderItems, {
|
||||||
|
source: 'admin_manual_sync',
|
||||||
|
autoOnly: false,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||||
|
const defaults = await ensureWorkerPlatformDefaults()
|
||||||
|
const now = nowIso()
|
||||||
|
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 2500)
|
||||||
|
const depositThresholdAmount = normalizeAmountFen(
|
||||||
|
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
|
||||||
|
DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||||
|
)
|
||||||
|
const requiredDepositAmount =
|
||||||
|
payload.requiredDepositAmount === undefined
|
||||||
|
? rewardAmount > depositThresholdAmount
|
||||||
|
? rewardAmount
|
||||||
|
: 0
|
||||||
|
: normalizeAmountFen(payload.requiredDepositAmount, 0)
|
||||||
|
const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true'
|
||||||
|
const fields = normalizeRequirementFields(payload.fields)
|
||||||
|
const platformOrderId = String(
|
||||||
|
payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`,
|
||||||
|
).trim()
|
||||||
|
const productName = String(payload.productName || '指挥官秘钥1个').trim()
|
||||||
|
const material = materialComplete
|
||||||
|
? {
|
||||||
|
collect: {
|
||||||
|
complete: true,
|
||||||
|
submittedAt: now,
|
||||||
|
fields: fields.reduce<Record<string, string>>((result, field) => {
|
||||||
|
result[field.key] = String(field.mockValue || `${field.label}测试值`)
|
||||||
|
return result
|
||||||
|
}, {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
const workOrder = await createWorkOrder({
|
||||||
|
workOrderNo: randomId('WO'),
|
||||||
|
platformOrderId,
|
||||||
|
productName,
|
||||||
|
categoryId: defaults.category?.id || null,
|
||||||
|
status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||||
|
rewardAmount,
|
||||||
|
requiredDepositAmount,
|
||||||
|
depositThresholdAmount,
|
||||||
|
materialJson: JSON.stringify(material),
|
||||||
|
requirementJson: JSON.stringify({ fields }),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
if (!workOrder) {
|
||||||
|
throw createHttpError('接单工单创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'work_order_create_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await createWorkOrderEvent({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: 'dev_mock',
|
||||||
|
eventType: 'mock_created',
|
||||||
|
fromStatus: '',
|
||||||
|
toStatus: workOrder.status,
|
||||||
|
payloadJson: JSON.stringify({ platformOrderId, productName, rewardAmount }),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
return { order: mapWorkOrderAdmin(workOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishAdminWorkOrder(workOrderId: number | string) {
|
||||||
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
|
if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) {
|
||||||
|
throw createHttpError('只有未分配订单可以发布到大厅', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_publish_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const now = nowIso()
|
||||||
|
const updated = await updateWorkOrder(workOrder.id, {
|
||||||
|
status: WORK_ORDER_STATUS.OPEN,
|
||||||
|
published_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
await createWorkOrderEvent({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: '',
|
||||||
|
eventType: 'published',
|
||||||
|
fromStatus: workOrder.status,
|
||||||
|
toStatus: WORK_ORDER_STATUS.OPEN,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpublishAdminWorkOrder(workOrderId: number | string) {
|
||||||
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
|
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
|
||||||
|
throw createHttpError('只有待抢单且未被接走的订单可以下架', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_unpublish_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const now = nowIso()
|
||||||
|
const updated = await updateWorkOrder(workOrder.id, {
|
||||||
|
status: WORK_ORDER_STATUS.UNASSIGNED,
|
||||||
|
published_at: null,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
await createWorkOrderEvent({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: '',
|
||||||
|
eventType: 'unpublished',
|
||||||
|
fromStatus: workOrder.status,
|
||||||
|
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAdminWorkOrderProblem(
|
||||||
|
workOrderId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
) {
|
||||||
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
|
if (
|
||||||
|
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(
|
||||||
|
workOrder.status as never,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw createHttpError('当前状态不能标记问题单', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_problem_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const now = nowIso()
|
||||||
|
const note = String(payload.note || payload.problemNote || '').trim()
|
||||||
|
const updated = await updateWorkOrder(workOrder.id, {
|
||||||
|
status: WORK_ORDER_STATUS.PROBLEM,
|
||||||
|
problem_note: note,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
await createWorkOrderEvent({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: '',
|
||||||
|
eventType: 'marked_problem',
|
||||||
|
fromStatus: workOrder.status,
|
||||||
|
toStatus: WORK_ORDER_STATUS.PROBLEM,
|
||||||
|
payloadJson: JSON.stringify({ note }),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAdminProblemWorkOrder(
|
||||||
|
workOrderId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
) {
|
||||||
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
|
if (workOrder.status !== WORK_ORDER_STATUS.PROBLEM) {
|
||||||
|
throw createHttpError('只有问题单可以执行处置', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_problem_resolution_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = normalizeProblemResolutionAction(payload.action)
|
||||||
|
const note = String(payload.note || '').trim()
|
||||||
|
if (!note) {
|
||||||
|
throw createHttpError('请填写处置备注', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_order_problem_resolution_note_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await resolveProblemWorkOrder({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
action,
|
||||||
|
note,
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
if (!updated) {
|
||||||
|
throw createHttpError('问题单处置失败,订单状态可能已变化', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_problem_resolution_conflict',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { order: mapWorkOrderAdmin(updated) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptAdminWorkOrder(workOrderId: number | string) {
|
||||||
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
|
if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
|
||||||
|
throw createHttpError('只有待验收订单可以通过验收', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_accept_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const now = nowIso()
|
||||||
|
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
if (failureReason === 'sharing_pending_submissions') {
|
||||||
|
throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_sharing_pending_submissions',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!updated) {
|
||||||
|
throw createHttpError('验收失败,订单状态可能已变化', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_accept_conflict',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminWorkerPlatformSummary() {
|
||||||
|
const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([
|
||||||
|
listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }),
|
||||||
|
listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.PENDING_MATERIAL }),
|
||||||
|
listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.OPEN }),
|
||||||
|
listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.IN_PROGRESS }),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
pendingWorkerCount: pendingWorkers.total,
|
||||||
|
pendingMaterialCount: pendingMaterial.total,
|
||||||
|
openOrderCount: openOrders.total,
|
||||||
|
inProgressOrderCount: inProgressOrders.total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncWorkerOrdersForSourceOrder(
|
||||||
|
order: OrderRow,
|
||||||
|
orderItems: OrderItemRow[],
|
||||||
|
options: { source?: string; autoOnly?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const rules = await listWorkProductRules({ enabled: true })
|
||||||
|
const created: WorkOrderRow[] = []
|
||||||
|
const skipped: Array<{ orderItemId: number; reason: string }> = []
|
||||||
|
|
||||||
|
for (const item of orderItems) {
|
||||||
|
const rule = resolveMatchingProductRule(order, item, rules)
|
||||||
|
if (!rule) {
|
||||||
|
skipped.push({ orderItemId: Number(item.id), reason: 'rule_not_matched' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (options.autoOnly && !rule.auto_create) {
|
||||||
|
skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (await getWorkOrderByOrderItemId(item.id)) {
|
||||||
|
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields)
|
||||||
|
const rewardAmount = Number(rule.reward_amount || 0)
|
||||||
|
const depositThresholdAmount = Number(
|
||||||
|
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||||
|
)
|
||||||
|
const requiredDepositAmount =
|
||||||
|
Number(rule.required_deposit_amount || 0) ||
|
||||||
|
(rewardAmount > depositThresholdAmount ? rewardAmount : 0)
|
||||||
|
const now = nowIso()
|
||||||
|
const materialComplete = fields.every((field) => !field.required)
|
||||||
|
const workOrder = await createWorkOrder({
|
||||||
|
workOrderNo: randomId('WO'),
|
||||||
|
orderId: Number(order.id),
|
||||||
|
orderItemId: Number(item.id),
|
||||||
|
taskId: null,
|
||||||
|
platformOrderId: order.platform_order_id,
|
||||||
|
productName: item.sku_name || item.sku_code,
|
||||||
|
categoryId: rule.category_id,
|
||||||
|
status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||||
|
rewardAmount,
|
||||||
|
requiredDepositAmount,
|
||||||
|
depositThresholdAmount,
|
||||||
|
sharingEnabled: rule.sharing_enabled === true,
|
||||||
|
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||||
|
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
|
||||||
|
materialJson: JSON.stringify({
|
||||||
|
source: {
|
||||||
|
orderId: Number(order.id),
|
||||||
|
orderItemId: Number(item.id),
|
||||||
|
provider: order.provider,
|
||||||
|
platform: order.platform,
|
||||||
|
shopId: order.shop_id,
|
||||||
|
skuCode: item.sku_code,
|
||||||
|
skuName: item.sku_name,
|
||||||
|
quantity: Number(item.quantity || 1),
|
||||||
|
syncSource: options.source || 'source_order',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
requirementJson: JSON.stringify({ fields }),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (workOrder) {
|
||||||
|
created.push(workOrder)
|
||||||
|
await createWorkOrderEvent({
|
||||||
|
workOrderId: workOrder.id,
|
||||||
|
actorType: 'system',
|
||||||
|
actorId: options.source || 'source_order',
|
||||||
|
eventType: 'source_synced',
|
||||||
|
fromStatus: '',
|
||||||
|
toStatus: workOrder.status,
|
||||||
|
payloadJson: JSON.stringify({
|
||||||
|
ruleKey: rule.rule_key,
|
||||||
|
orderId: order.id,
|
||||||
|
orderItemId: item.id,
|
||||||
|
}),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: created.map(mapWorkOrderAdmin),
|
||||||
|
skipped,
|
||||||
|
createdCount: created.length,
|
||||||
|
skippedCount: skipped.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSourceOrder(payload: JsonObject): Promise<OrderRow> {
|
||||||
|
const orderId = normalizeOptionalId(payload.orderId || payload.sourceOrderId)
|
||||||
|
if (orderId) {
|
||||||
|
const order = await getOrderById(orderId)
|
||||||
|
if (order) {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
throw createHttpError('源订单不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'source_order_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformOrderId = String(payload.platformOrderId || payload.orderNo || '').trim()
|
||||||
|
if (!platformOrderId) {
|
||||||
|
throw createHttpError('请填写源订单号', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'source_order_no_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await findLatestOrderByAnyPlatformOrderId(platformOrderId)
|
||||||
|
if (!order) {
|
||||||
|
throw createHttpError('未找到源订单,请确认订单号是否正确', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'source_order_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './mappers.js'
|
||||||
|
export * from './worker-service.js'
|
||||||
|
export * from './admin-service.js'
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
|
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||||
|
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||||
|
import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
|
||||||
|
import {
|
||||||
|
acceptWorkOrderAndSettle,
|
||||||
|
addWorkerWalletCredit,
|
||||||
|
countWorkerAcceptedOrders,
|
||||||
|
countWorkerActiveOrders,
|
||||||
|
countWorkCategoryUsages,
|
||||||
|
countWorkerLevelUsages,
|
||||||
|
countWorkOrderPendingSharingSubmissions,
|
||||||
|
cancelWorkerWorkOrder,
|
||||||
|
countWorkerCancellationsSince,
|
||||||
|
createWorkOrder,
|
||||||
|
createWorkOrderEvent,
|
||||||
|
createWorkerFinanceRequest,
|
||||||
|
createWorkerUser,
|
||||||
|
deleteWorkCategory,
|
||||||
|
deleteWorkerLevel,
|
||||||
|
getWorkerFinanceRequestById,
|
||||||
|
getWorkerFinanceRequestSummary,
|
||||||
|
getWorkCategoryById,
|
||||||
|
getWorkCategoryByKey,
|
||||||
|
getWorkerLevelById,
|
||||||
|
getWorkerLevelByKey,
|
||||||
|
getWorkerUserByInviteCode,
|
||||||
|
getWorkerUserById,
|
||||||
|
getWorkerUserByUsername,
|
||||||
|
getWorkOrderById,
|
||||||
|
getWorkOrderByOrderItemId,
|
||||||
|
getWorkOrderShare,
|
||||||
|
grabWorkOrder,
|
||||||
|
incrementWorkerSessionVersion,
|
||||||
|
joinWorkOrderShare,
|
||||||
|
listAllWorkCategories,
|
||||||
|
listPendingMaterialWorkOrdersByPlatformOrderId,
|
||||||
|
listWorkerFinanceRequests,
|
||||||
|
listWorkerSharesByWorker,
|
||||||
|
listWorkerWalletLedgers,
|
||||||
|
listWorkCategories,
|
||||||
|
listWorkOrders,
|
||||||
|
listWorkProductRules,
|
||||||
|
listWorkerLevels,
|
||||||
|
listWorkerUsers,
|
||||||
|
listWorkOrderShares,
|
||||||
|
listWorkOrderSharesByOrderIds,
|
||||||
|
resolveProblemWorkOrder,
|
||||||
|
reviewWorkerFinanceRequest,
|
||||||
|
submitWorkOrderShareAcceptance,
|
||||||
|
updateWorkOrder,
|
||||||
|
updateWorkerPassword,
|
||||||
|
updateWorkerUser,
|
||||||
|
upsertWorkCategory,
|
||||||
|
upsertWorkProductRule,
|
||||||
|
upsertWorkerLevel,
|
||||||
|
type WorkCategoryRow,
|
||||||
|
type WorkerFinanceRequestRow,
|
||||||
|
type WorkOrderRow,
|
||||||
|
type WorkProductRuleRow,
|
||||||
|
type WorkerLevelRow,
|
||||||
|
type WorkOrderShareRow,
|
||||||
|
type WorkerWalletLedgerRow,
|
||||||
|
type WorkerUserRow,
|
||||||
|
type GrabWorkOrderFailureReason,
|
||||||
|
type ProblemWorkOrderResolutionAction,
|
||||||
|
} from '../../repositories/worker-platform/index.js'
|
||||||
|
import type { JsonObject } from '../../types/json.js'
|
||||||
|
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||||
|
import { createHttpError } from '../../utils/http.js'
|
||||||
|
import { randomId } from '../../utils/random.js'
|
||||||
|
import { nowIso, addHours } from '../../utils/time.js'
|
||||||
|
import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js'
|
||||||
|
import {
|
||||||
|
normalizeStoredFileUrl,
|
||||||
|
refreshStoredFileUrl,
|
||||||
|
refreshUploadedFileUrls,
|
||||||
|
} from '../file-storage/file-storage-service.js'
|
||||||
|
import {
|
||||||
|
getWorkerFinanceConfig,
|
||||||
|
saveWorkerFinanceConfig,
|
||||||
|
} from './worker-finance-config-service.js'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const DEFAULT_LEVEL_KEY = 'vip1'
|
||||||
|
export const DEFAULT_LEVEL_NAME = 'VIP1'
|
||||||
|
export const DEFAULT_CATEGORY_KEY = 'default'
|
||||||
|
export const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
|
||||||
|
export const INVITE_CODE_LENGTH = 8
|
||||||
|
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')
|
||||||
|
return {
|
||||||
|
token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`,
|
||||||
|
expiresAt,
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return {
|
||||||
|
levelId: Number(level.id),
|
||||||
|
levelKey: level.level_key,
|
||||||
|
name: level.name,
|
||||||
|
sortOrder: Number(level.sort_order || 100),
|
||||||
|
status: level.status,
|
||||||
|
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
|
||||||
|
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
||||||
|
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
|
||||||
|
createdAt: level.created_at,
|
||||||
|
updatedAt: level.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkCategory(category: WorkCategoryRow | null | undefined) {
|
||||||
|
if (!category) return null
|
||||||
|
return {
|
||||||
|
categoryId: Number(category.id),
|
||||||
|
categoryKey: category.category_key,
|
||||||
|
name: category.name,
|
||||||
|
sortOrder: Number(category.sort_order || 100),
|
||||||
|
status: category.status,
|
||||||
|
createdAt: category.created_at,
|
||||||
|
updatedAt: category.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) {
|
||||||
|
if (!rule) return null
|
||||||
|
const requirement = safeParseJson(rule.requirement_json)
|
||||||
|
return {
|
||||||
|
ruleId: Number(rule.id),
|
||||||
|
ruleKey: rule.rule_key,
|
||||||
|
provider: rule.provider || '',
|
||||||
|
platform: rule.platform || '',
|
||||||
|
shopId: rule.shop_id || '',
|
||||||
|
skuCode: rule.sku_code || '',
|
||||||
|
productName: rule.product_name || '',
|
||||||
|
matchType: rule.match_type || 'contains',
|
||||||
|
categoryId: rule.category_id ? Number(rule.category_id) : null,
|
||||||
|
categoryName: rule.category_name || '',
|
||||||
|
enabled: Boolean(rule.enabled),
|
||||||
|
autoCreate: Boolean(rule.auto_create),
|
||||||
|
rewardAmount: Number(rule.reward_amount || 0),
|
||||||
|
requiredDepositAmount: Number(rule.required_deposit_amount || 0),
|
||||||
|
depositThresholdAmount: Number(
|
||||||
|
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||||
|
),
|
||||||
|
sharing: {
|
||||||
|
enabled: rule.sharing_enabled === true,
|
||||||
|
totalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||||
|
unitReward: Number(rule.sharing_unit_reward || 0),
|
||||||
|
},
|
||||||
|
requirement: {
|
||||||
|
fields: normalizeRequirementFields(requirement.fields),
|
||||||
|
},
|
||||||
|
sortOrder: Number(rule.sort_order || 100),
|
||||||
|
createdAt: rule.created_at,
|
||||||
|
updatedAt: rule.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkerUser(worker: WorkerUserRow) {
|
||||||
|
return {
|
||||||
|
workerId: Number(worker.id),
|
||||||
|
username: worker.username,
|
||||||
|
displayName: worker.display_name || worker.username,
|
||||||
|
phone: worker.phone,
|
||||||
|
status: worker.status,
|
||||||
|
reviewNote: worker.review_note || '',
|
||||||
|
inviteCode: worker.invite_code || '',
|
||||||
|
inviter: worker.inviter_id
|
||||||
|
? {
|
||||||
|
workerId: Number(worker.inviter_id),
|
||||||
|
username: worker.inviter_username || '',
|
||||||
|
displayName: worker.inviter_display_name || worker.inviter_username || '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
level: worker.level_id
|
||||||
|
? {
|
||||||
|
levelId: Number(worker.level_id),
|
||||||
|
levelKey: worker.level_key || '',
|
||||||
|
name: worker.level_name || '',
|
||||||
|
permissions: resolveWorkerPermissions(worker),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
wallet: {
|
||||||
|
availableAmount: Number(worker.available_amount || 0),
|
||||||
|
frozenDepositAmount: Number(worker.frozen_deposit_amount || 0),
|
||||||
|
totalCreditedAmount: Number(worker.total_credited_amount || 0),
|
||||||
|
totalSettledAmount: Number(worker.total_settled_amount || 0),
|
||||||
|
},
|
||||||
|
createdAt: worker.created_at,
|
||||||
|
updatedAt: worker.updated_at,
|
||||||
|
reviewedAt: worker.reviewed_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) {
|
||||||
|
if (!ledger) return null
|
||||||
|
return {
|
||||||
|
ledgerId: Number(ledger.id),
|
||||||
|
ledgerType: ledger.ledger_type,
|
||||||
|
amount: Number(ledger.amount || 0),
|
||||||
|
balanceAfter: Number(ledger.balance_after || 0),
|
||||||
|
frozenAfter: Number(ledger.frozen_after || 0),
|
||||||
|
relatedWorkOrderId: ledger.related_work_order_id
|
||||||
|
? Number(ledger.related_work_order_id)
|
||||||
|
: null,
|
||||||
|
auditStatus: ledger.audit_status || '',
|
||||||
|
note: ledger.note || '',
|
||||||
|
payload: safeParseJson(ledger.payload_json),
|
||||||
|
createdAt: ledger.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) {
|
||||||
|
if (!request) return null
|
||||||
|
return {
|
||||||
|
requestId: Number(request.id),
|
||||||
|
requestType: request.request_type || '',
|
||||||
|
status: request.status || '',
|
||||||
|
amount: Number(request.amount || 0),
|
||||||
|
accountChannel: request.account_channel || '',
|
||||||
|
accountName: request.account_name || '',
|
||||||
|
accountNo: request.account_no || '',
|
||||||
|
note: request.note || '',
|
||||||
|
reviewedNote: request.reviewed_note || '',
|
||||||
|
payload: safeParseJson(request.payload_json),
|
||||||
|
createdAt: request.created_at,
|
||||||
|
updatedAt: request.updated_at,
|
||||||
|
reviewedAt: request.reviewed_at,
|
||||||
|
worker: request.worker_id
|
||||||
|
? {
|
||||||
|
workerId: Number(request.worker_id),
|
||||||
|
username: request.worker_username || '',
|
||||||
|
displayName: request.worker_display_name || request.worker_username || '',
|
||||||
|
phone: request.worker_phone || '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||||
|
return {
|
||||||
|
workOrderId: Number(workOrder.id),
|
||||||
|
orderId: workOrder.order_id ? Number(workOrder.order_id) : null,
|
||||||
|
orderItemId: workOrder.order_item_id ? Number(workOrder.order_item_id) : null,
|
||||||
|
workOrderNo: workOrder.work_order_no,
|
||||||
|
platformOrderId: workOrder.platform_order_id,
|
||||||
|
productName: workOrder.product_name,
|
||||||
|
categoryName: workOrder.category_name || '',
|
||||||
|
status: workOrder.status,
|
||||||
|
rewardAmount: Number(workOrder.reward_amount || 0),
|
||||||
|
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||||
|
sharing: {
|
||||||
|
enabled: workOrder.sharing_enabled === true,
|
||||||
|
totalQuantity: Number(workOrder.sharing_total_quantity || 1),
|
||||||
|
unitReward: Number(workOrder.sharing_unit_reward || 0),
|
||||||
|
},
|
||||||
|
depositThresholdAmount: Number(
|
||||||
|
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||||
|
),
|
||||||
|
material: safeParseJson(workOrder.material_json),
|
||||||
|
requirement: safeParseJson(workOrder.requirement_json),
|
||||||
|
acceptance: mapAcceptanceForResponse(workOrder.acceptance_json),
|
||||||
|
problemNote: workOrder.problem_note || '',
|
||||||
|
worker: workOrder.assigned_worker_id
|
||||||
|
? {
|
||||||
|
workerId: Number(workOrder.assigned_worker_id),
|
||||||
|
username: workOrder.worker_username || '',
|
||||||
|
displayName: workOrder.worker_display_name || '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
publishedAt: workOrder.published_at,
|
||||||
|
assignedAt: workOrder.assigned_at,
|
||||||
|
submittedAt: workOrder.submitted_at,
|
||||||
|
acceptedAt: workOrder.accepted_at,
|
||||||
|
createdAt: workOrder.created_at,
|
||||||
|
updatedAt: workOrder.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkOrderForWorker(
|
||||||
|
workOrder: WorkOrderRow,
|
||||||
|
permissions: { depositFreeAmount: number },
|
||||||
|
myShare?: WorkOrderShareRow | null,
|
||||||
|
) {
|
||||||
|
const mapped = mapWorkOrderAdmin(workOrder)
|
||||||
|
const {
|
||||||
|
requiredDepositAmount,
|
||||||
|
depositThresholdAmount,
|
||||||
|
worker: _worker,
|
||||||
|
orderId: _orderId,
|
||||||
|
orderItemId: _orderItemId,
|
||||||
|
...visibleOrder
|
||||||
|
} = mapped
|
||||||
|
return {
|
||||||
|
...visibleOrder,
|
||||||
|
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
|
||||||
|
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||||
|
return {
|
||||||
|
shareId: Number(share.id),
|
||||||
|
workOrderId: Number(share.work_order_id),
|
||||||
|
workerId: Number(share.worker_id),
|
||||||
|
quantity: Number(share.quantity || 0),
|
||||||
|
unitReward: Number(share.unit_reward || 0),
|
||||||
|
shareReward: Number(share.share_reward || 0),
|
||||||
|
shareDeposit: Number(share.share_deposit || 0),
|
||||||
|
status: share.status,
|
||||||
|
acceptance: safeParseJson(share.acceptance_json),
|
||||||
|
submittedAt: share.submitted_at,
|
||||||
|
acceptedAt: share.accepted_at,
|
||||||
|
createdAt: share.created_at,
|
||||||
|
updatedAt: share.updated_at,
|
||||||
|
worker: share.worker_id
|
||||||
|
? {
|
||||||
|
workerId: Number(share.worker_id),
|
||||||
|
username: share.worker_username || '',
|
||||||
|
displayName: share.worker_display_name || share.worker_username || '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkOrderPublic(workOrder: WorkOrderRow) {
|
||||||
|
return {
|
||||||
|
workOrderId: Number(workOrder.id),
|
||||||
|
workOrderNo: workOrder.work_order_no,
|
||||||
|
platformOrderId: workOrder.platform_order_id,
|
||||||
|
productName: workOrder.product_name,
|
||||||
|
status: workOrder.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapCollectLookupOrder(workOrder: WorkOrderRow) {
|
||||||
|
return {
|
||||||
|
...mapWorkOrderPublic(workOrder),
|
||||||
|
fields: resolveRequirementFields(workOrder),
|
||||||
|
values: resolveCollectSubmittedFieldValues(workOrder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWallet(wallet: Awaited<ReturnType<typeof addWorkerWalletCredit>>) {
|
||||||
|
return {
|
||||||
|
workerId: Number(wallet?.worker_id || 0),
|
||||||
|
availableAmount: Number(wallet?.available_amount || 0),
|
||||||
|
frozenDepositAmount: Number(wallet?.frozen_deposit_amount || 0),
|
||||||
|
totalCreditedAmount: Number(wallet?.total_credited_amount || 0),
|
||||||
|
totalSettledAmount: Number(wallet?.total_settled_amount || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRequirementFields(workOrder: WorkOrderRow) {
|
||||||
|
return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRequirementFields(value: unknown) {
|
||||||
|
const rawFields = Array.isArray(value) ? value : []
|
||||||
|
const fields = rawFields
|
||||||
|
.map((item) => {
|
||||||
|
const source =
|
||||||
|
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
|
||||||
|
const key = String(source.key || '').trim()
|
||||||
|
if (!key) return null
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: String(source.label || key).trim(),
|
||||||
|
required: source.required !== false,
|
||||||
|
mockValue: String(source.mockValue || '').trim(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
|
||||||
|
Boolean(item),
|
||||||
|
)
|
||||||
|
|
||||||
|
return fields.length > 0
|
||||||
|
? fields
|
||||||
|
: [
|
||||||
|
{ key: 'gameAccount', label: '游戏账号', required: true, mockValue: 'test_account' },
|
||||||
|
{ key: 'serverName', label: '区服', required: true, mockValue: '安卓一区' },
|
||||||
|
{ key: 'roleName', label: '角色名', required: true, mockValue: '测试角色' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
|
||||||
|
const directFields = Array.isArray(payload.fields) ? payload.fields : null
|
||||||
|
const requirement =
|
||||||
|
payload.requirement &&
|
||||||
|
typeof payload.requirement === 'object' &&
|
||||||
|
!Array.isArray(payload.requirement)
|
||||||
|
? (payload.requirement as JsonObject)
|
||||||
|
: {}
|
||||||
|
if (directFields) {
|
||||||
|
return normalizeRequirementFields(directFields)
|
||||||
|
}
|
||||||
|
if (Array.isArray(requirement.fields)) {
|
||||||
|
return normalizeRequirementFields(requirement.fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textFields = normalizeRequirementFieldsText(
|
||||||
|
payload.fieldsText || payload.requiredFieldsText,
|
||||||
|
)
|
||||||
|
return textFields.length > 0 ? textFields : normalizeRequirementFields([])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRequirementFieldsText(value: unknown) {
|
||||||
|
return String(value || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line, index) => {
|
||||||
|
const text = line.trim()
|
||||||
|
if (!text) return null
|
||||||
|
const delimiter = text.includes(':') ? ':' : ':'
|
||||||
|
if (text.includes(delimiter)) {
|
||||||
|
const [rawKey, ...labelParts] = text.split(delimiter)
|
||||||
|
const key = String(rawKey || '').trim()
|
||||||
|
if (!key) return null
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: labelParts.join(delimiter).trim() || key,
|
||||||
|
required: true,
|
||||||
|
mockValue: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: `field${index + 1}`,
|
||||||
|
label: text,
|
||||||
|
required: true,
|
||||||
|
mockValue: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
|
||||||
|
Boolean(item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatchingProductRule(
|
||||||
|
order: OrderRow,
|
||||||
|
item: OrderItemRow,
|
||||||
|
rules: WorkProductRuleRow[],
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
rules.find((rule) => {
|
||||||
|
if (!matchesOptionalText(rule.provider, order.provider)) return false
|
||||||
|
if (!matchesOptionalText(rule.platform, order.platform)) return false
|
||||||
|
if (!matchesOptionalText(rule.shop_id, order.shop_id)) return false
|
||||||
|
|
||||||
|
const skuCode = String(item.sku_code || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
const skuName = String(item.sku_name || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
const ruleSkuCode = String(rule.sku_code || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
const ruleProductName = String(rule.product_name || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
if (ruleSkuCode && ruleSkuCode !== skuCode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!ruleProductName) {
|
||||||
|
return Boolean(ruleSkuCode)
|
||||||
|
}
|
||||||
|
if (rule.match_type === 'exact') {
|
||||||
|
return ruleProductName === skuName || ruleProductName === skuCode
|
||||||
|
}
|
||||||
|
return skuName.includes(ruleProductName) || skuCode.includes(ruleProductName)
|
||||||
|
}) || null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) {
|
||||||
|
const ruleText = String(ruleValue || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (!ruleText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
ruleText ===
|
||||||
|
String(sourceValue || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSubmittedFields(value: unknown) {
|
||||||
|
const source =
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(source)
|
||||||
|
.filter(([key]) => !['orderNo', 'platformOrderId', 'fields', 'material'].includes(key))
|
||||||
|
.map(([key, currentValue]) => [key, String(currentValue || '').trim()]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.map((item) => String(item || '').trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeUploadedFiles(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
const source =
|
||||||
|
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
|
||||||
|
const objectKey = String(source.objectKey || source.object_key || '').trim()
|
||||||
|
const url = String(source.url || '').trim()
|
||||||
|
if (!objectKey && !url) return null
|
||||||
|
return {
|
||||||
|
objectKey,
|
||||||
|
url: normalizeStoredFileUrl(url, objectKey),
|
||||||
|
thumbnailUrl: normalizeStoredFileUrl(
|
||||||
|
source.thumbnailUrl || source.thumbnail_url || url,
|
||||||
|
objectKey,
|
||||||
|
),
|
||||||
|
mediumUrl: normalizeStoredFileUrl(source.mediumUrl || source.medium_url || url, objectKey),
|
||||||
|
filename: String(source.filename || '').trim(),
|
||||||
|
contentType: String(source.contentType || source.content_type || '').trim(),
|
||||||
|
size: normalizeInteger(source.size, 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is {
|
||||||
|
objectKey: string
|
||||||
|
url: string
|
||||||
|
thumbnailUrl: string
|
||||||
|
mediumUrl: string
|
||||||
|
filename: string
|
||||||
|
contentType: string
|
||||||
|
size: number
|
||||||
|
} => Boolean(item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAcceptanceForResponse(value: unknown) {
|
||||||
|
const acceptance = safeParseJson(value)
|
||||||
|
const files = normalizeUploadedFiles(acceptance.files).map((file) =>
|
||||||
|
refreshUploadedFileUrls(file),
|
||||||
|
)
|
||||||
|
const imageUrls = normalizeStringArray(acceptance.imageUrls).map((url) =>
|
||||||
|
refreshStoredFileUrl(url),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...acceptance,
|
||||||
|
files,
|
||||||
|
imageUrls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCollectSubmittedFieldValues(workOrder: WorkOrderRow) {
|
||||||
|
const material = safeParseJson(workOrder.material_json)
|
||||||
|
const collect =
|
||||||
|
material.collect && typeof material.collect === 'object' && !Array.isArray(material.collect)
|
||||||
|
? safeParseJson(material.collect)
|
||||||
|
: {}
|
||||||
|
return normalizeSubmittedFields(collect.fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReviewStatus(value: unknown) {
|
||||||
|
const status = String(value || '').trim()
|
||||||
|
if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status
|
||||||
|
return 'pending_review'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction {
|
||||||
|
const action = String(value || '').trim()
|
||||||
|
if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) {
|
||||||
|
return action as ProblemWorkOrderResolutionAction
|
||||||
|
}
|
||||||
|
throw createHttpError('问题单处置方式不正确', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_order_problem_resolution_action_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throwGrabWorkOrderFailure(failureReason: GrabWorkOrderFailureReason | null): never {
|
||||||
|
if (failureReason === 'worker_active_order_limit') {
|
||||||
|
throw createHttpError('已达到当前等级最大同时接单量', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_active_order_limit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (failureReason === 'worker_deposit_insufficient') {
|
||||||
|
throw createHttpError('余额不足,无法冻结所需押金', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_deposit_insufficient',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw createHttpError('抢单失败,订单可能已被抢走', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_grab_conflict',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEnabledStatus(value: unknown) {
|
||||||
|
return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWalletLedgerType(value: unknown) {
|
||||||
|
const ledgerType = String(value || '').trim()
|
||||||
|
if (!ledgerType) return ''
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'manual_credit',
|
||||||
|
'withdraw_paid',
|
||||||
|
'deposit_freeze',
|
||||||
|
'deposit_release',
|
||||||
|
'deposit_deduction',
|
||||||
|
'reward_settlement',
|
||||||
|
].includes(ledgerType)
|
||||||
|
) {
|
||||||
|
return ledgerType
|
||||||
|
}
|
||||||
|
throw createHttpError('钱包流水类型不正确', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_wallet_ledger_type_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFinanceRequestType(value: unknown) {
|
||||||
|
const requestType = String(value || '').trim()
|
||||||
|
if (!requestType) return ''
|
||||||
|
if (['recharge', 'withdraw'].includes(requestType)) {
|
||||||
|
return requestType
|
||||||
|
}
|
||||||
|
throw createHttpError('资金申请类型不正确', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_finance_request_type_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFinanceRequestStatus(value: unknown) {
|
||||||
|
const status = String(value || '').trim()
|
||||||
|
if (!status) return ''
|
||||||
|
if (['pending', 'approved', 'rejected', 'cancelled'].includes(status)) {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
throw createHttpError('资金申请状态不正确', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_finance_request_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAdminFinanceReviewStatus(value: unknown) {
|
||||||
|
const status = String(value || '').trim()
|
||||||
|
if (['approved', 'rejected', 'cancelled'].includes(status)) {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
throw createHttpError('审核状态不正确', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_finance_review_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWithdrawChannel(value: unknown) {
|
||||||
|
const channel = String(value || '').trim()
|
||||||
|
if (['alipay', 'wechat', 'bank'].includes(channel)) {
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
throw createHttpError('提现方式不正确', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_withdraw_channel_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMatchType(value: unknown) {
|
||||||
|
const matchType = String(value || '').trim()
|
||||||
|
if (matchType === 'exact') return 'exact'
|
||||||
|
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()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
return key || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeUsername(value: unknown): string {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePassword(value: unknown): string {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWorkerUsername(username: string) {
|
||||||
|
if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) {
|
||||||
|
throw createHttpError('接单账号格式无效,需为 3-32 位字母数字或 ._-', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_username_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWorkerPassword(password: string) {
|
||||||
|
if (password.length < 6) {
|
||||||
|
throw createHttpError('接单密码至少 6 位', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_password_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const parsed = Number(text)
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return fallback
|
||||||
|
return Math.round(parsed * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProofFiles(value: unknown): Array<Record<string, unknown>> {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const files: Array<Record<string, unknown>> = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== 'object') continue
|
||||||
|
const file = item as Record<string, unknown>
|
||||||
|
const url = String(file.url || '').trim()
|
||||||
|
if (!url) continue
|
||||||
|
files.push({
|
||||||
|
url,
|
||||||
|
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(),
|
||||||
|
mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(),
|
||||||
|
objectKey: String(file.objectKey || file.object_key || '').trim(),
|
||||||
|
originalFilename: String(
|
||||||
|
file.originalFilename || file.original_filename || file.name || '',
|
||||||
|
).trim(),
|
||||||
|
contentType: String(file.contentType || file.content_type || '').trim(),
|
||||||
|
sizeBytes: Number(file.sizeBytes || file.size_bytes || 0),
|
||||||
|
})
|
||||||
|
if (files.length >= 10) break
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIgnorableWorkerAuthError(error: unknown) {
|
||||||
|
return IGNORABLE_WORKER_AUTH_ERROR_CODES.has(
|
||||||
|
String((error as { errorCode?: string })?.errorCode || '').trim(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
|
||||||
|
'worker_auth_required',
|
||||||
|
'worker_auth_invalid',
|
||||||
|
'worker_auth_expired',
|
||||||
|
'worker_auth_user_invalid',
|
||||||
|
'worker_auth_stale',
|
||||||
|
])
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { WorkOrderRow } from '../../repositories/worker-platform-repo.js'
|
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
|
||||||
import { resolveCollectSubmitTargetWorkOrder } from './worker-platform-service.js'
|
import { resolveCollectSubmitTargetWorkOrder } from './index.js'
|
||||||
|
|
||||||
function buildWorkOrderRow(
|
function buildWorkOrderRow(
|
||||||
overrides: Partial<WorkOrderRow> = {},
|
overrides: Partial<WorkOrderRow> = {},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import type { AdminSession } from '../services/admin/admin-auth-service.js'
|
import type { AdminSession } from '../services/admin/admin-auth-service.js'
|
||||||
import type { WorkerSession } from '../services/worker-platform/worker-platform-service.js'
|
import type { WorkerSession } from '../services/worker-platform/index.js'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
|||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
FormOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
|
import {
|
||||||
|
acceptAdminWorkOrder,
|
||||||
|
createAdminMockWorkOrder,
|
||||||
|
creditAdminWorkerWallet,
|
||||||
|
deleteAdminWorkCategory,
|
||||||
|
deleteAdminWorkerLevel,
|
||||||
|
fetchAdminWorkerFinanceConfig,
|
||||||
|
fetchAdminWorkerFinanceRequests,
|
||||||
|
fetchAdminWorkCategories,
|
||||||
|
fetchAdminWorkOrderSharing,
|
||||||
|
fetchAdminWorkProductRules,
|
||||||
|
fetchAdminWorkerLevels,
|
||||||
|
fetchAdminWorkerPlatformSummary,
|
||||||
|
fetchAdminWorkerUsers,
|
||||||
|
fetchAdminWorkOrders,
|
||||||
|
markAdminWorkOrderProblem,
|
||||||
|
publishAdminWorkOrder,
|
||||||
|
reviewAdminWorkerFinanceRequest,
|
||||||
|
resolveAdminProblemWorkOrder,
|
||||||
|
reviewAdminWorkerUser,
|
||||||
|
saveAdminWorkerFinanceConfig,
|
||||||
|
saveAdminWorkCategory,
|
||||||
|
saveAdminWorkProductRule,
|
||||||
|
saveAdminWorkerLevel,
|
||||||
|
submitAdminWorkOrderMaterial,
|
||||||
|
syncAdminWorkerOrdersFromSource,
|
||||||
|
unpublishAdminWorkOrder,
|
||||||
|
updateAdminWorkOrderSharing,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
CollectField,
|
||||||
|
UploadedFile,
|
||||||
|
WorkCategory,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
|
WorkProductRule,
|
||||||
|
WorkerFinanceConfig,
|
||||||
|
WorkerFinanceRequest,
|
||||||
|
WorkerLevel,
|
||||||
|
WorkerUser,
|
||||||
|
} from '@/types/worker-platform'
|
||||||
|
import {
|
||||||
|
ADMIN_DEFAULT_PAGE_SIZE,
|
||||||
|
buildAdminTablePagination,
|
||||||
|
} from '@/utils/admin-pagination'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
|
export default function CategoriesPanel() {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [editingCategory, setEditingCategory] = useState<WorkCategory | null>(null)
|
||||||
|
const [deletingCategory, setDeletingCategory] = useState<WorkCategory | null>(null)
|
||||||
|
const categoriesQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-categories'],
|
||||||
|
queryFn: () => fetchAdminWorkCategories(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refreshCategories() {
|
||||||
|
await categoriesQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(category: WorkCategory) {
|
||||||
|
setEditingCategory(category)
|
||||||
|
form.setFieldsValue({
|
||||||
|
categoryKey: category.categoryKey,
|
||||||
|
name: category.name,
|
||||||
|
sortOrder: category.sortOrder,
|
||||||
|
status: category.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingCategory(null)
|
||||||
|
form.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCategory(values: {
|
||||||
|
categoryKey: string
|
||||||
|
name: string
|
||||||
|
sortOrder?: number
|
||||||
|
status?: string
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await saveAdminWorkCategory(values)
|
||||||
|
message.success(editingCategory ? '分类已更新' : '分类已保存')
|
||||||
|
cancelEdit()
|
||||||
|
await refreshCategories()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCategory(category: WorkCategory) {
|
||||||
|
try {
|
||||||
|
await saveAdminWorkCategory({
|
||||||
|
categoryKey: category.categoryKey,
|
||||||
|
name: category.name,
|
||||||
|
sortOrder: category.sortOrder,
|
||||||
|
status: category.status === 'active' ? 'disabled' : 'active',
|
||||||
|
})
|
||||||
|
message.success(category.status === 'active' ? '分类已停用' : '分类已启用')
|
||||||
|
await refreshCategories()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDelete() {
|
||||||
|
if (!deletingCategory) return
|
||||||
|
try {
|
||||||
|
await deleteAdminWorkCategory(deletingCategory.categoryId)
|
||||||
|
message.success('分类已删除')
|
||||||
|
setDeletingCategory(null)
|
||||||
|
await refreshCategories()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
setDeletingCategory(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<WorkCategory> = [
|
||||||
|
{ title: '分类', dataIndex: 'name' },
|
||||||
|
{ title: '标识', dataIndex: 'categoryKey' },
|
||||||
|
{ title: '排序', dataIndex: 'sortOrder', width: 100 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Tag color={row.status === 'active' ? 'green' : 'default'}>
|
||||||
|
{row.status === 'active' ? '启用' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 240,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button size="small" onClick={() => startEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => toggleCategory(row)}>
|
||||||
|
{row.status === 'active' ? '停用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" danger onClick={() => setDeletingCategory(row)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="platform-panel-stack">
|
||||||
|
<Card title={editingCategory ? `编辑分类:${editingCategory.name}` : '保存分类'} bordered={false}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
initialValues={{ status: 'active', sortOrder: 100 }}
|
||||||
|
onFinish={saveCategory}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="标识"
|
||||||
|
name="categoryKey"
|
||||||
|
rules={[{ required: true, message: '请输入标识' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="名称"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: '请输入名称' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="排序" name="sortOrder">
|
||||||
|
<InputNumber min={0} max={9999} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="状态" name="status">
|
||||||
|
<Select
|
||||||
|
style={{ width: 110 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: '启用' },
|
||||||
|
{ value: 'disabled', label: '停用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
{editingCategory ? <Button onClick={cancelEdit}>取消</Button> : null}
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card title="分类列表" bordered={false}>
|
||||||
|
<Table<WorkCategory>
|
||||||
|
rowKey="categoryId"
|
||||||
|
loading={categoriesQuery.isLoading}
|
||||||
|
dataSource={categoriesQuery.data?.data.items || []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Modal
|
||||||
|
title="删除分类"
|
||||||
|
open={Boolean(deletingCategory)}
|
||||||
|
onOk={submitDelete}
|
||||||
|
onCancel={() => setDeletingCategory(null)}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Typography.Text>
|
||||||
|
确定删除分类「{deletingCategory?.name}」?已引用该分类的工单和物品规则将无法删除,需先处理。
|
||||||
|
</Typography.Text>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
FormOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
|
import {
|
||||||
|
acceptAdminWorkOrder,
|
||||||
|
createAdminMockWorkOrder,
|
||||||
|
creditAdminWorkerWallet,
|
||||||
|
deleteAdminWorkCategory,
|
||||||
|
deleteAdminWorkerLevel,
|
||||||
|
fetchAdminWorkerFinanceConfig,
|
||||||
|
fetchAdminWorkerFinanceRequests,
|
||||||
|
fetchAdminWorkCategories,
|
||||||
|
fetchAdminWorkOrderSharing,
|
||||||
|
fetchAdminWorkProductRules,
|
||||||
|
fetchAdminWorkerLevels,
|
||||||
|
fetchAdminWorkerPlatformSummary,
|
||||||
|
fetchAdminWorkerUsers,
|
||||||
|
fetchAdminWorkOrders,
|
||||||
|
markAdminWorkOrderProblem,
|
||||||
|
publishAdminWorkOrder,
|
||||||
|
reviewAdminWorkerFinanceRequest,
|
||||||
|
resolveAdminProblemWorkOrder,
|
||||||
|
reviewAdminWorkerUser,
|
||||||
|
saveAdminWorkerFinanceConfig,
|
||||||
|
saveAdminWorkCategory,
|
||||||
|
saveAdminWorkProductRule,
|
||||||
|
saveAdminWorkerLevel,
|
||||||
|
submitAdminWorkOrderMaterial,
|
||||||
|
syncAdminWorkerOrdersFromSource,
|
||||||
|
unpublishAdminWorkOrder,
|
||||||
|
updateAdminWorkOrderSharing,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
CollectField,
|
||||||
|
UploadedFile,
|
||||||
|
WorkCategory,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
|
WorkProductRule,
|
||||||
|
WorkerFinanceConfig,
|
||||||
|
WorkerFinanceRequest,
|
||||||
|
WorkerLevel,
|
||||||
|
WorkerUser,
|
||||||
|
} from '@/types/worker-platform'
|
||||||
|
import {
|
||||||
|
ADMIN_DEFAULT_PAGE_SIZE,
|
||||||
|
buildAdminTablePagination,
|
||||||
|
} from '@/utils/admin-pagination'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import { asRecord, formatMoney, formatSharingShareStatus, resolveSharingShareStatusColor } from './shared'
|
||||||
|
type FinanceConfigFormValues = {
|
||||||
|
recharge?: {
|
||||||
|
enabled?: boolean
|
||||||
|
channelName?: string
|
||||||
|
accountName?: string
|
||||||
|
accountNo?: string
|
||||||
|
qrCodeImageList?: UploadedFile[]
|
||||||
|
instructions?: string
|
||||||
|
}
|
||||||
|
withdraw?: {
|
||||||
|
enabled?: boolean
|
||||||
|
instructions?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinanceReviewAction = 'approved' | 'rejected' | 'cancelled'
|
||||||
|
|
||||||
|
export default function FinancePanel() {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [configForm] = Form.useForm<FinanceConfigFormValues>()
|
||||||
|
const [reviewForm] = Form.useForm<{ reviewedNote?: string }>()
|
||||||
|
const [requestStatus, setRequestStatus] = useState('')
|
||||||
|
const [requestType, setRequestType] = useState('')
|
||||||
|
const [keywordInput, setKeywordInput] = useState('')
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||||
|
const [savingConfig, setSavingConfig] = useState(false)
|
||||||
|
const [reviewing, setReviewing] = useState(false)
|
||||||
|
const [reviewState, setReviewState] = useState<{
|
||||||
|
action: FinanceReviewAction
|
||||||
|
request: WorkerFinanceRequest
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const financeConfigQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-finance-config'],
|
||||||
|
queryFn: () => fetchAdminWorkerFinanceConfig(),
|
||||||
|
})
|
||||||
|
const financeRequestsQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'admin-worker-platform-finance-requests',
|
||||||
|
requestStatus,
|
||||||
|
requestType,
|
||||||
|
keyword,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchAdminWorkerFinanceRequests({
|
||||||
|
status: requestStatus,
|
||||||
|
requestType,
|
||||||
|
keyword,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const financeRequestsPagination = financeRequestsQuery.data?.data.pagination
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const config = financeConfigQuery.data?.data
|
||||||
|
if (!config) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configForm.setFieldsValue(mapFinanceConfigToFormValues(config))
|
||||||
|
}, [financeConfigQuery.data, configForm])
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-finance-config'],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-finance-requests'],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-workers'],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFinanceConfig(values: FinanceConfigFormValues) {
|
||||||
|
setSavingConfig(true)
|
||||||
|
try {
|
||||||
|
await saveAdminWorkerFinanceConfig({
|
||||||
|
recharge: {
|
||||||
|
enabled: values.recharge?.enabled !== false,
|
||||||
|
channelName: String(values.recharge?.channelName || '').trim(),
|
||||||
|
accountName: String(values.recharge?.accountName || '').trim(),
|
||||||
|
accountNo: String(values.recharge?.accountNo || '').trim(),
|
||||||
|
qrCodeImage: values.recharge?.qrCodeImageList?.[0] || null,
|
||||||
|
instructions: String(values.recharge?.instructions || '').trim(),
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
enabled: values.withdraw?.enabled !== false,
|
||||||
|
instructions: String(values.withdraw?.instructions || '').trim(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
message.success('资金配置已保存')
|
||||||
|
await refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '保存资金配置失败')
|
||||||
|
} finally {
|
||||||
|
setSavingConfig(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReviewModal(request: WorkerFinanceRequest, action: FinanceReviewAction) {
|
||||||
|
setReviewState({ request, action })
|
||||||
|
reviewForm.setFieldsValue({ reviewedNote: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFinanceReview(values: { reviewedNote?: string }) {
|
||||||
|
if (!reviewState) return
|
||||||
|
setReviewing(true)
|
||||||
|
try {
|
||||||
|
await reviewAdminWorkerFinanceRequest(reviewState.request.requestId, {
|
||||||
|
status: reviewState.action,
|
||||||
|
reviewedNote: String(values.reviewedNote || '').trim(),
|
||||||
|
})
|
||||||
|
message.success(`${formatFinanceReviewAction(reviewState.action)}成功`)
|
||||||
|
setReviewState(null)
|
||||||
|
reviewForm.resetFields()
|
||||||
|
await refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '处理资金申请失败')
|
||||||
|
} finally {
|
||||||
|
setReviewing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestColumns: TableColumnsType<WorkerFinanceRequest> = [
|
||||||
|
{
|
||||||
|
title: '打手',
|
||||||
|
minWidth: 180,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text strong>
|
||||||
|
{row.worker?.displayName || row.worker?.username || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{row.worker?.username || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Tag color={row.requestType === 'withdraw' ? 'green' : 'blue'}>
|
||||||
|
{formatFinanceRequestType(row.requestType)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => formatMoney(row.amount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收款信息',
|
||||||
|
minWidth: 240,
|
||||||
|
render: (_, row) => renderFinanceRequestAccount(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '付款凭证',
|
||||||
|
minWidth: 140,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.requestType === 'recharge' ? (
|
||||||
|
<ImagePreviewList files={getRechargeProofs(row)} size={48} />
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
minWidth: 260,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text>{row.note || '-'}</Typography.Text>
|
||||||
|
{row.reviewedNote ? (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
审核备注:{row.reviewedNote}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Tag color={resolveFinanceRequestStatusColor(row.status)}>
|
||||||
|
{formatFinanceRequestStatus(row.status)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
width: 180,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text>{formatAdminDateTime(row.createdAt)}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{row.reviewedAt ? `审核:${formatAdminDateTime(row.reviewedAt)}` : '待审核'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 240,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.status === 'pending' ? (
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="primary" onClick={() => openReviewModal(row, 'approved')}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => openReviewModal(row, 'rejected')}>拒绝</Button>
|
||||||
|
<Button danger onClick={() => openReviewModal(row, 'cancelled')}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">已处理</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="platform-panel-stack">
|
||||||
|
<Card
|
||||||
|
title="资金配置"
|
||||||
|
bordered={false}
|
||||||
|
loading={financeConfigQuery.isLoading}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={financeConfigQuery.isFetching}
|
||||||
|
onClick={() => financeConfigQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{financeConfigQuery.error ? (
|
||||||
|
<Typography.Text type="danger">
|
||||||
|
{financeConfigQuery.error instanceof Error
|
||||||
|
? financeConfigQuery.error.message
|
||||||
|
: '读取资金配置失败'}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
<Form
|
||||||
|
form={configForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={submitFinanceConfig}
|
||||||
|
initialValues={mapFinanceConfigToFormValues()}
|
||||||
|
>
|
||||||
|
<Card title="充值入口" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
label="开启充值申请"
|
||||||
|
name={['recharge', 'enabled']}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="收款通道名称" name={['recharge', 'channelName']}>
|
||||||
|
<Input placeholder="如:支付宝扫码、微信收款" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="收款人" name={['recharge', 'accountName']}>
|
||||||
|
<Input placeholder="请输入收款人姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="收款账号" name={['recharge', 'accountNo']}>
|
||||||
|
<Input placeholder="请输入支付宝账号、微信号或银行卡号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="收款二维码" name={['recharge', 'qrCodeImageList']}>
|
||||||
|
<ImageUpload scene="worker-finance" scope="admin" maxCount={1} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="充值说明" name={['recharge', 'instructions']}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="例如:转账后请填写付款时间、付款尾号、截图说明。"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="提现入口" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
label="开启提现申请"
|
||||||
|
name={['withdraw', 'enabled']}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="提现说明" name={['withdraw', 'instructions']}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="例如:每天 18:00 前审核,当日打款;银行卡请备注开户行。"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button type="primary" htmlType="submit" loading={savingConfig}>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="资金申请"
|
||||||
|
bordered={false}
|
||||||
|
extra={
|
||||||
|
<Space wrap>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
value={keywordInput}
|
||||||
|
placeholder="搜索打手账号/昵称/手机号"
|
||||||
|
style={{ width: 240 }}
|
||||||
|
onChange={(event) => setKeywordInput(event.target.value)}
|
||||||
|
onSearch={(value) => {
|
||||||
|
setKeyword(String(value || '').trim())
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={requestType}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部类型' },
|
||||||
|
{ value: 'recharge', label: '充值申请' },
|
||||||
|
{ value: 'withdraw', label: '提现申请' },
|
||||||
|
]}
|
||||||
|
onChange={(nextType) => {
|
||||||
|
setRequestType(nextType)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={requestStatus}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'pending', label: '待处理' },
|
||||||
|
{ value: 'approved', label: '已通过' },
|
||||||
|
{ value: 'rejected', label: '已拒绝' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
]}
|
||||||
|
onChange={(nextStatus) => {
|
||||||
|
setRequestStatus(nextStatus)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={financeRequestsQuery.isFetching}
|
||||||
|
onClick={() => financeRequestsQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<WorkerFinanceRequest>
|
||||||
|
rowKey="requestId"
|
||||||
|
loading={financeRequestsQuery.isLoading}
|
||||||
|
dataSource={financeRequestsQuery.data?.data.items || []}
|
||||||
|
columns={requestColumns}
|
||||||
|
pagination={buildAdminTablePagination({
|
||||||
|
current: financeRequestsPagination?.page || page,
|
||||||
|
pageSize: financeRequestsPagination?.pageSize || pageSize,
|
||||||
|
total: financeRequestsPagination?.total || 0,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
scroll={{ x: 1380 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={reviewState ? `${formatFinanceReviewAction(reviewState.action)}资金申请` : '处理资金申请'}
|
||||||
|
open={Boolean(reviewState)}
|
||||||
|
destroyOnHidden
|
||||||
|
confirmLoading={reviewing}
|
||||||
|
onCancel={() => {
|
||||||
|
setReviewState(null)
|
||||||
|
reviewForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => reviewForm.submit()}
|
||||||
|
>
|
||||||
|
{reviewState ? (
|
||||||
|
<Space direction="vertical" size={16} className="full-width">
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="打手">
|
||||||
|
{reviewState.request.worker?.displayName ||
|
||||||
|
reviewState.request.worker?.username ||
|
||||||
|
'-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="申请类型">
|
||||||
|
{formatFinanceRequestType(reviewState.request.requestType)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="金额">
|
||||||
|
{formatMoney(reviewState.request.amount)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收款信息">
|
||||||
|
{renderFinanceRequestAccountText(reviewState.request)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{reviewState.request.requestType === 'recharge' ? (
|
||||||
|
<>
|
||||||
|
<Descriptions.Item label="付款时间">
|
||||||
|
{getRechargePaidAt(reviewState.request)
|
||||||
|
? formatAdminDateTime(getRechargePaidAt(reviewState.request))
|
||||||
|
: '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="付款凭证">
|
||||||
|
<ImagePreviewList files={getRechargeProofs(reviewState.request)} size={88} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Descriptions.Item label="申请备注">
|
||||||
|
{reviewState.request.note || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Form form={reviewForm} layout="vertical" onFinish={submitFinanceReview}>
|
||||||
|
<Form.Item label="审核备注" name="reviewedNote">
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder={`请输入${formatFinanceReviewAction(reviewState.action)}备注`}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFinanceConfigToFormValues(
|
||||||
|
config?: WorkerFinanceConfig,
|
||||||
|
): FinanceConfigFormValues {
|
||||||
|
return {
|
||||||
|
recharge: {
|
||||||
|
enabled: config?.recharge.enabled !== false,
|
||||||
|
channelName: config?.recharge.channelName || '',
|
||||||
|
accountName: config?.recharge.accountName || '',
|
||||||
|
accountNo: config?.recharge.accountNo || '',
|
||||||
|
qrCodeImageList: config?.recharge.qrCodeImage ? [config.recharge.qrCodeImage] : [],
|
||||||
|
instructions: config?.recharge.instructions || '',
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
enabled: config?.withdraw.enabled !== false,
|
||||||
|
instructions: config?.withdraw.instructions || '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFinanceReviewAction(action: FinanceReviewAction) {
|
||||||
|
if (action === 'approved') return '通过'
|
||||||
|
if (action === 'rejected') return '拒绝'
|
||||||
|
return '取消'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFinanceRequestType(requestType: string) {
|
||||||
|
if (requestType === 'recharge') return '充值申请'
|
||||||
|
if (requestType === 'withdraw') return '提现申请'
|
||||||
|
return requestType || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFinanceRequestStatus(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
approved: '已通过',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
return labels[status] || status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFinanceRequestStatusColor(status: string) {
|
||||||
|
if (status === 'approved') return 'green'
|
||||||
|
if (status === 'pending') return 'gold'
|
||||||
|
if (status === 'rejected') return 'red'
|
||||||
|
if (status === 'cancelled') return 'default'
|
||||||
|
return 'blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFinanceRequestAccount(request: WorkerFinanceRequest) {
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text>{renderFinanceRequestAccountText(request)}</Typography.Text>
|
||||||
|
{request.requestType === 'withdraw' && request.accountNo ? (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{maskFinanceAccount(request.accountNo)}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFinanceRequestAccountText(request: WorkerFinanceRequest) {
|
||||||
|
if (request.requestType === 'withdraw') {
|
||||||
|
const channel = formatFinanceChannel(request.accountChannel)
|
||||||
|
const accountName = request.accountName || '-'
|
||||||
|
return `${channel} / ${accountName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rechargeSnapshot = getRechargeSnapshot(request)
|
||||||
|
if (
|
||||||
|
rechargeSnapshot.channelName ||
|
||||||
|
rechargeSnapshot.accountName ||
|
||||||
|
rechargeSnapshot.accountNo
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
rechargeSnapshot.channelName,
|
||||||
|
rechargeSnapshot.accountName,
|
||||||
|
rechargeSnapshot.accountNo ? maskFinanceAccount(rechargeSnapshot.accountNo) : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return '线下充值,等待管理员核对'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRechargeSnapshot(request: WorkerFinanceRequest) {
|
||||||
|
const payload = asRecord(request.payload)
|
||||||
|
const financeConfig = asRecord(payload.financeConfig)
|
||||||
|
return {
|
||||||
|
channelName: String(financeConfig.channelName || '').trim(),
|
||||||
|
accountName: String(financeConfig.accountName || '').trim(),
|
||||||
|
accountNo: String(financeConfig.accountNo || '').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRechargeProofs(request: WorkerFinanceRequest): UploadedFile[] {
|
||||||
|
if (request.requestType !== 'recharge') return []
|
||||||
|
const payload = asRecord(request.payload)
|
||||||
|
const rawFiles = Array.isArray(payload.proofFiles) ? payload.proofFiles : []
|
||||||
|
return rawFiles
|
||||||
|
.map((item) => {
|
||||||
|
const file = asRecord(item)
|
||||||
|
const url = String(file.url || '').trim()
|
||||||
|
if (!url) return null
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
mediumUrl: String(file.mediumUrl || '').trim(),
|
||||||
|
thumbnailUrl: String(file.thumbnailUrl || '').trim(),
|
||||||
|
} as UploadedFile
|
||||||
|
})
|
||||||
|
.filter((item): item is UploadedFile => Boolean(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRechargePaidAt(request: WorkerFinanceRequest): string {
|
||||||
|
const payload = asRecord(request.payload)
|
||||||
|
return String(payload.paidAt || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFinanceChannel(channel: string) {
|
||||||
|
if (channel === 'alipay') return '支付宝'
|
||||||
|
if (channel === 'wechat') return '微信收款'
|
||||||
|
if (channel === 'bank') return '银行卡'
|
||||||
|
if (channel === 'manual') return '线下转账'
|
||||||
|
return channel || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskFinanceAccount(value: string) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) return '-'
|
||||||
|
if (text.length <= 8) return text
|
||||||
|
return `${text.slice(0, 4)} **** ${text.slice(-4)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
FormOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
|
import {
|
||||||
|
acceptAdminWorkOrder,
|
||||||
|
createAdminMockWorkOrder,
|
||||||
|
creditAdminWorkerWallet,
|
||||||
|
deleteAdminWorkCategory,
|
||||||
|
deleteAdminWorkerLevel,
|
||||||
|
fetchAdminWorkerFinanceConfig,
|
||||||
|
fetchAdminWorkerFinanceRequests,
|
||||||
|
fetchAdminWorkCategories,
|
||||||
|
fetchAdminWorkOrderSharing,
|
||||||
|
fetchAdminWorkProductRules,
|
||||||
|
fetchAdminWorkerLevels,
|
||||||
|
fetchAdminWorkerPlatformSummary,
|
||||||
|
fetchAdminWorkerUsers,
|
||||||
|
fetchAdminWorkOrders,
|
||||||
|
markAdminWorkOrderProblem,
|
||||||
|
publishAdminWorkOrder,
|
||||||
|
reviewAdminWorkerFinanceRequest,
|
||||||
|
resolveAdminProblemWorkOrder,
|
||||||
|
reviewAdminWorkerUser,
|
||||||
|
saveAdminWorkerFinanceConfig,
|
||||||
|
saveAdminWorkCategory,
|
||||||
|
saveAdminWorkProductRule,
|
||||||
|
saveAdminWorkerLevel,
|
||||||
|
submitAdminWorkOrderMaterial,
|
||||||
|
syncAdminWorkerOrdersFromSource,
|
||||||
|
unpublishAdminWorkOrder,
|
||||||
|
updateAdminWorkOrderSharing,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
CollectField,
|
||||||
|
UploadedFile,
|
||||||
|
WorkCategory,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
|
WorkProductRule,
|
||||||
|
WorkerFinanceConfig,
|
||||||
|
WorkerFinanceRequest,
|
||||||
|
WorkerLevel,
|
||||||
|
WorkerUser,
|
||||||
|
} from '@/types/worker-platform'
|
||||||
|
import {
|
||||||
|
ADMIN_DEFAULT_PAGE_SIZE,
|
||||||
|
buildAdminTablePagination,
|
||||||
|
} from '@/utils/admin-pagination'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import { formatMoney } from './shared'
|
||||||
|
|
||||||
|
export default function LevelsPanel() {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const levelsQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-levels'],
|
||||||
|
queryFn: () => fetchAdminWorkerLevels(),
|
||||||
|
})
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [editingLevel, setEditingLevel] = useState<WorkerLevel | null>(null)
|
||||||
|
const [deletingLevel, setDeletingLevel] = useState<WorkerLevel | null>(null)
|
||||||
|
|
||||||
|
async function refreshLevels() {
|
||||||
|
await levelsQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(level: WorkerLevel) {
|
||||||
|
setEditingLevel(level)
|
||||||
|
form.setFieldsValue({
|
||||||
|
levelKey: level.levelKey,
|
||||||
|
name: level.name,
|
||||||
|
depositFreeAmount: level.depositFreeAmount,
|
||||||
|
maxActiveOrders: level.maxActiveOrders,
|
||||||
|
upgradeThreshold: level.upgradeThreshold,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingLevel(null)
|
||||||
|
form.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLevel(values: {
|
||||||
|
levelKey: string
|
||||||
|
name: string
|
||||||
|
depositFreeAmount?: number
|
||||||
|
maxActiveOrders?: number
|
||||||
|
upgradeThreshold?: number
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await saveAdminWorkerLevel(values)
|
||||||
|
message.success(editingLevel ? '等级已更新' : '等级已保存')
|
||||||
|
cancelEdit()
|
||||||
|
await refreshLevels()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLevel(level: WorkerLevel) {
|
||||||
|
try {
|
||||||
|
await saveAdminWorkerLevel({
|
||||||
|
levelKey: level.levelKey,
|
||||||
|
name: level.name,
|
||||||
|
depositFreeAmount: level.depositFreeAmount,
|
||||||
|
maxActiveOrders: level.maxActiveOrders,
|
||||||
|
upgradeThreshold: level.upgradeThreshold,
|
||||||
|
status: level.status === 'active' ? 'disabled' : 'active',
|
||||||
|
})
|
||||||
|
message.success(level.status === 'active' ? '等级已停用' : '等级已启用')
|
||||||
|
await refreshLevels()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDelete() {
|
||||||
|
if (!deletingLevel) return
|
||||||
|
try {
|
||||||
|
await deleteAdminWorkerLevel(deletingLevel.levelId)
|
||||||
|
message.success('等级已删除')
|
||||||
|
setDeletingLevel(null)
|
||||||
|
await refreshLevels()
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-workers'],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
setDeletingLevel(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<WorkerLevel> = [
|
||||||
|
{ title: '等级', dataIndex: 'name' },
|
||||||
|
{ title: '标识', dataIndex: 'levelKey' },
|
||||||
|
{
|
||||||
|
title: '免押额度',
|
||||||
|
render: (_, row) => formatMoney(row.depositFreeAmount),
|
||||||
|
},
|
||||||
|
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
|
||||||
|
{ title: '升级所需累计完成', dataIndex: 'upgradeThreshold' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Tag color={row.status === 'active' ? 'green' : 'default'}>
|
||||||
|
{row.status === 'active' ? '启用' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 240,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button size="small" onClick={() => startEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => toggleLevel(row)}>
|
||||||
|
{row.status === 'active' ? '停用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" danger onClick={() => setDeletingLevel(row)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="platform-panel-stack">
|
||||||
|
<Card
|
||||||
|
title={editingLevel ? `编辑等级:${editingLevel.name}` : '保存等级'}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
initialValues={{
|
||||||
|
levelKey: 'vip1',
|
||||||
|
name: 'VIP1',
|
||||||
|
depositFreeAmount: 0,
|
||||||
|
maxActiveOrders: 1,
|
||||||
|
upgradeThreshold: 0,
|
||||||
|
}}
|
||||||
|
onFinish={saveLevel}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="标识"
|
||||||
|
name="levelKey"
|
||||||
|
rules={[{ required: true, message: '请输入标识' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="名称"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: '请输入名称' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="免押额度" name="depositFreeAmount">
|
||||||
|
<InputNumber min={0} step={10} addonAfter="元" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="最大同时接单" name="maxActiveOrders">
|
||||||
|
<InputNumber min={1} max={999} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="升级所需累计完成单数"
|
||||||
|
name="upgradeThreshold"
|
||||||
|
tooltip="打手累计验收通过该单数后自动升级到本等级"
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100000} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
{editingLevel ? <Button onClick={cancelEdit}>取消</Button> : null}
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card title="等级列表" bordered={false}>
|
||||||
|
<Table<WorkerLevel>
|
||||||
|
rowKey="levelId"
|
||||||
|
loading={levelsQuery.isLoading}
|
||||||
|
dataSource={levelsQuery.data?.data.items || []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Modal
|
||||||
|
title="删除等级"
|
||||||
|
open={Boolean(deletingLevel)}
|
||||||
|
onOk={submitDelete}
|
||||||
|
onCancel={() => setDeletingLevel(null)}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Typography.Text>
|
||||||
|
确定删除等级「{deletingLevel?.name}」?正在使用该等级的打手将无法删除,需先调整。
|
||||||
|
</Typography.Text>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
FormOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
|
import {
|
||||||
|
acceptAdminWorkOrder,
|
||||||
|
createAdminMockWorkOrder,
|
||||||
|
creditAdminWorkerWallet,
|
||||||
|
deleteAdminWorkCategory,
|
||||||
|
deleteAdminWorkerLevel,
|
||||||
|
fetchAdminWorkerFinanceConfig,
|
||||||
|
fetchAdminWorkerFinanceRequests,
|
||||||
|
fetchAdminWorkCategories,
|
||||||
|
fetchAdminWorkOrderSharing,
|
||||||
|
fetchAdminWorkProductRules,
|
||||||
|
fetchAdminWorkerLevels,
|
||||||
|
fetchAdminWorkerPlatformSummary,
|
||||||
|
fetchAdminWorkerUsers,
|
||||||
|
fetchAdminWorkOrders,
|
||||||
|
markAdminWorkOrderProblem,
|
||||||
|
publishAdminWorkOrder,
|
||||||
|
reviewAdminWorkerFinanceRequest,
|
||||||
|
resolveAdminProblemWorkOrder,
|
||||||
|
reviewAdminWorkerUser,
|
||||||
|
saveAdminWorkerFinanceConfig,
|
||||||
|
saveAdminWorkCategory,
|
||||||
|
saveAdminWorkProductRule,
|
||||||
|
saveAdminWorkerLevel,
|
||||||
|
submitAdminWorkOrderMaterial,
|
||||||
|
syncAdminWorkerOrdersFromSource,
|
||||||
|
unpublishAdminWorkOrder,
|
||||||
|
updateAdminWorkOrderSharing,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
CollectField,
|
||||||
|
UploadedFile,
|
||||||
|
WorkCategory,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
|
WorkProductRule,
|
||||||
|
WorkerFinanceConfig,
|
||||||
|
WorkerFinanceRequest,
|
||||||
|
WorkerLevel,
|
||||||
|
WorkerUser,
|
||||||
|
} from '@/types/worker-platform'
|
||||||
|
import {
|
||||||
|
ADMIN_DEFAULT_PAGE_SIZE,
|
||||||
|
buildAdminTablePagination,
|
||||||
|
} from '@/utils/admin-pagination'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import { formatMoney } from './shared'
|
||||||
|
|
||||||
|
export default function ProductRulesPanel() {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const rulesQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-product-rules'],
|
||||||
|
queryFn: () => fetchAdminWorkProductRules(),
|
||||||
|
})
|
||||||
|
const categoriesQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-categories'],
|
||||||
|
queryFn: () => fetchAdminWorkCategories(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveRule(values: {
|
||||||
|
ruleKey: string
|
||||||
|
provider?: string
|
||||||
|
platform?: string
|
||||||
|
shopId?: string
|
||||||
|
skuCode?: string
|
||||||
|
productName?: string
|
||||||
|
matchType?: string
|
||||||
|
categoryId?: number
|
||||||
|
rewardAmount?: number
|
||||||
|
requiredDepositAmount?: number
|
||||||
|
depositThresholdAmount?: number
|
||||||
|
sharingEnabled?: boolean
|
||||||
|
sharingTotalQuantity?: number
|
||||||
|
sharingUnitReward?: number
|
||||||
|
sharingTotalAmount?: number
|
||||||
|
fieldsText?: string
|
||||||
|
enabled?: boolean
|
||||||
|
autoCreate?: boolean
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await saveAdminWorkProductRule(values)
|
||||||
|
message.success('物品规则已保存')
|
||||||
|
form.resetFields()
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-product-rules'],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-summary'],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSharingRuleForm(next: {
|
||||||
|
sharingTotalQuantity?: number
|
||||||
|
sharingUnitReward?: number
|
||||||
|
sharingTotalAmount?: number
|
||||||
|
}) {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const unitReward = Number(next.sharingUnitReward ?? values.sharingUnitReward ?? 0)
|
||||||
|
const totalQuantity = Number(
|
||||||
|
next.sharingTotalQuantity ?? values.sharingTotalQuantity ?? 1,
|
||||||
|
)
|
||||||
|
const totalAmount = Number(next.sharingTotalAmount ?? values.sharingTotalAmount ?? 0)
|
||||||
|
if (next.sharingTotalAmount !== undefined) {
|
||||||
|
const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1
|
||||||
|
form.setFieldsValue({
|
||||||
|
sharingTotalQuantity: quantity,
|
||||||
|
sharingTotalAmount: quantity * unitReward,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.setFieldsValue({
|
||||||
|
sharingTotalAmount: unitReward * totalQuantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<WorkProductRule> = [
|
||||||
|
{
|
||||||
|
title: '规则',
|
||||||
|
minWidth: 260,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text strong>
|
||||||
|
{row.productName || row.skuCode}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">{row.ruleKey}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SKU',
|
||||||
|
dataIndex: 'skuCode',
|
||||||
|
width: 180,
|
||||||
|
render: (value) => String(value || '-'),
|
||||||
|
},
|
||||||
|
{ title: '分类', width: 140, render: (_, row) => row.categoryName || '-' },
|
||||||
|
{
|
||||||
|
title: '接单金额',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => formatMoney(row.rewardAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '押金',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '匹配',
|
||||||
|
width: 130,
|
||||||
|
render: (_, row) => (row.matchType === 'exact' ? '精确' : '包含'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 150,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Tag color={row.enabled ? 'green' : 'default'}>
|
||||||
|
{row.enabled ? '启用' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
{row.autoCreate ? <Tag color="blue">自动创建</Tag> : null}
|
||||||
|
{row.sharing?.enabled ? (
|
||||||
|
<Tag color="purple">
|
||||||
|
拼单 {formatMoney(row.sharing.unitReward)}/份
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="platform-panel-stack">
|
||||||
|
<Card title="保存物品规则" bordered={false}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
className="worker-rule-form"
|
||||||
|
initialValues={{
|
||||||
|
platform: 'kuaishou',
|
||||||
|
matchType: 'contains',
|
||||||
|
rewardAmount: 25,
|
||||||
|
depositThresholdAmount: 200,
|
||||||
|
enabled: true,
|
||||||
|
autoCreate: false,
|
||||||
|
sharingEnabled: false,
|
||||||
|
sharingTotalQuantity: 10,
|
||||||
|
sharingUnitReward: 5,
|
||||||
|
sharingTotalAmount: 50,
|
||||||
|
fieldsText:
|
||||||
|
'gameAccount:游戏账号\nserverName:区服\nroleName:角色名',
|
||||||
|
}}
|
||||||
|
onFinish={saveRule}
|
||||||
|
>
|
||||||
|
<div className="worker-rule-grid">
|
||||||
|
<Form.Item
|
||||||
|
label="规则标识"
|
||||||
|
name="ruleKey"
|
||||||
|
rules={[{ required: true, message: '请输入规则标识' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="skin-training" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="来源" name="provider">
|
||||||
|
<Input placeholder="留空通配,如 91kaquan" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="平台" name="platform">
|
||||||
|
<Input placeholder="kuaishou" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="店铺" name="shopId">
|
||||||
|
<Input placeholder="留空通配" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="SKU" name="skuCode">
|
||||||
|
<Input placeholder="优先精确匹配" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="商品名" name="productName">
|
||||||
|
<Input placeholder="用于包含/精确匹配" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="匹配方式" name="matchType">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'contains', label: '包含' },
|
||||||
|
{ value: 'exact', label: '精确' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="分类" name="categoryId">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
loading={categoriesQuery.isLoading}
|
||||||
|
options={(categoriesQuery.data?.data.items || []).map(
|
||||||
|
(item) => ({
|
||||||
|
value: item.categoryId,
|
||||||
|
label: item.name,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="接单金额" name="rewardAmount">
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="押金阈值" name="depositThresholdAmount">
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
step={10}
|
||||||
|
addonAfter="元"
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="固定押金" name="requiredDepositAmount">
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
step={10}
|
||||||
|
addonAfter="元"
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="排序" name="sortOrder">
|
||||||
|
<InputNumber min={0} max={9999} className="full-width" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="自动创建"
|
||||||
|
name="autoCreate"
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="启用拼单"
|
||||||
|
name="sharingEnabled"
|
||||||
|
valuePropName="checked"
|
||||||
|
tooltip="开启后按下方总数量与单价生成拼单工单"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('sharingEnabled') ? (
|
||||||
|
<Space size={16} wrap style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
label="拼单总数量"
|
||||||
|
name="sharingTotalQuantity"
|
||||||
|
rules={[{ required: true, message: '请填写总数量' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={100000}
|
||||||
|
addonAfter="份"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingRuleForm({
|
||||||
|
sharingTotalQuantity: Number(value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="拼单单价"
|
||||||
|
name="sharingUnitReward"
|
||||||
|
rules={[{ required: true, message: '请填写单价' }]}
|
||||||
|
tooltip="每个数量份对应的报酬,修改后总价自动重算"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingRuleForm({
|
||||||
|
sharingUnitReward: Number(value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="拼单总价"
|
||||||
|
name="sharingTotalAmount"
|
||||||
|
rules={[{ required: true, message: '请填写总价' }]}
|
||||||
|
tooltip="总价 = 单价 × 数量,修改总价会自动反推数量"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingRuleForm({
|
||||||
|
sharingTotalAmount: Number(value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="资料字段" name="fieldsText">
|
||||||
|
<Input.TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
保存规则
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
title="物品规则"
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={rulesQuery.isFetching}
|
||||||
|
onClick={() => rulesQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<Table<WorkProductRule>
|
||||||
|
rowKey="ruleId"
|
||||||
|
loading={rulesQuery.isLoading}
|
||||||
|
dataSource={rulesQuery.data?.data.items || []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,984 @@
|
|||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
FormOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
|
import {
|
||||||
|
acceptAdminWorkOrder,
|
||||||
|
createAdminMockWorkOrder,
|
||||||
|
creditAdminWorkerWallet,
|
||||||
|
deleteAdminWorkCategory,
|
||||||
|
deleteAdminWorkerLevel,
|
||||||
|
fetchAdminWorkerFinanceConfig,
|
||||||
|
fetchAdminWorkerFinanceRequests,
|
||||||
|
fetchAdminWorkCategories,
|
||||||
|
fetchAdminWorkOrderSharing,
|
||||||
|
fetchAdminWorkProductRules,
|
||||||
|
fetchAdminWorkerLevels,
|
||||||
|
fetchAdminWorkerPlatformSummary,
|
||||||
|
fetchAdminWorkerUsers,
|
||||||
|
fetchAdminWorkOrders,
|
||||||
|
markAdminWorkOrderProblem,
|
||||||
|
publishAdminWorkOrder,
|
||||||
|
reviewAdminWorkerFinanceRequest,
|
||||||
|
resolveAdminProblemWorkOrder,
|
||||||
|
reviewAdminWorkerUser,
|
||||||
|
saveAdminWorkerFinanceConfig,
|
||||||
|
saveAdminWorkCategory,
|
||||||
|
saveAdminWorkProductRule,
|
||||||
|
saveAdminWorkerLevel,
|
||||||
|
submitAdminWorkOrderMaterial,
|
||||||
|
syncAdminWorkerOrdersFromSource,
|
||||||
|
unpublishAdminWorkOrder,
|
||||||
|
updateAdminWorkOrderSharing,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
CollectField,
|
||||||
|
UploadedFile,
|
||||||
|
WorkCategory,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
|
WorkProductRule,
|
||||||
|
WorkerFinanceConfig,
|
||||||
|
WorkerFinanceRequest,
|
||||||
|
WorkerLevel,
|
||||||
|
WorkerUser,
|
||||||
|
} from '@/types/worker-platform'
|
||||||
|
import {
|
||||||
|
ADMIN_DEFAULT_PAGE_SIZE,
|
||||||
|
buildAdminTablePagination,
|
||||||
|
} from '@/utils/admin-pagination'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import {
|
||||||
|
getAcceptanceFiles,
|
||||||
|
getAcceptanceImageUrls,
|
||||||
|
getAcceptanceSubmittedAt,
|
||||||
|
getMaterialFields,
|
||||||
|
getMaterialDetailItems,
|
||||||
|
getRequirementFields,
|
||||||
|
renderDetailFieldValues,
|
||||||
|
renderMaterialSummary,
|
||||||
|
renderRequirementFieldValues,
|
||||||
|
formatMoney,
|
||||||
|
formatSharingShareStatus,
|
||||||
|
formatStatus,
|
||||||
|
resolveSharingShareStatusColor,
|
||||||
|
resolveStatusColor,
|
||||||
|
type DetailFieldItem,
|
||||||
|
} from './shared'
|
||||||
|
|
||||||
|
export default function WorkOrdersPanel() {
|
||||||
|
const { message, modal } = App.useApp()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||||
|
const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [problemForm] = Form.useForm()
|
||||||
|
const [resolutionForm] = Form.useForm()
|
||||||
|
const [materialForm] = Form.useForm()
|
||||||
|
const [sharingForm] = Form.useForm<{
|
||||||
|
enabled?: boolean
|
||||||
|
totalQuantity?: number
|
||||||
|
unitReward?: number
|
||||||
|
totalAmount?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const ordersQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-orders', status, page, pageSize],
|
||||||
|
queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }),
|
||||||
|
})
|
||||||
|
const ordersPagination = ordersQuery.data?.data.pagination
|
||||||
|
const sharingQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-order-sharing', sharingOrder?.workOrderId],
|
||||||
|
queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)),
|
||||||
|
enabled: Boolean(sharingOrder),
|
||||||
|
})
|
||||||
|
|
||||||
|
function openSharingConfigModal(row: WorkOrder) {
|
||||||
|
setSharingOrder(row)
|
||||||
|
const unitRewardFen = Number(row.sharing?.unitReward || 0)
|
||||||
|
const totalQuantity = Number(row.sharing?.totalQuantity || 1)
|
||||||
|
const unitRewardYuan = unitRewardFen / 100
|
||||||
|
sharingForm.setFieldsValue({
|
||||||
|
enabled: row.sharing?.enabled,
|
||||||
|
totalQuantity,
|
||||||
|
unitReward: unitRewardYuan,
|
||||||
|
totalAmount: unitRewardYuan * totalQuantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSharingForm(next: {
|
||||||
|
totalQuantity?: number
|
||||||
|
unitReward?: number
|
||||||
|
totalAmount?: number
|
||||||
|
}) {
|
||||||
|
const values = sharingForm.getFieldsValue()
|
||||||
|
const unitReward = Number(next.unitReward ?? values.unitReward ?? 0)
|
||||||
|
const totalQuantity = Number(next.totalQuantity ?? values.totalQuantity ?? 1)
|
||||||
|
const totalAmount = Number(next.totalAmount ?? values.totalAmount ?? 0)
|
||||||
|
if (next.totalAmount !== undefined) {
|
||||||
|
const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1
|
||||||
|
sharingForm.setFieldsValue({
|
||||||
|
totalQuantity: quantity,
|
||||||
|
totalAmount: quantity * unitReward,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sharingForm.setFieldsValue({
|
||||||
|
totalAmount: unitReward * totalQuantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSharingConfig(values: {
|
||||||
|
enabled?: boolean
|
||||||
|
totalQuantity?: number
|
||||||
|
unitReward?: number
|
||||||
|
totalAmount?: number
|
||||||
|
}) {
|
||||||
|
if (!sharingOrder) return
|
||||||
|
const succeeded = await runAction(
|
||||||
|
() =>
|
||||||
|
updateAdminWorkOrderSharing(sharingOrder.workOrderId, {
|
||||||
|
enabled: values.enabled !== false,
|
||||||
|
totalQuantity: Number(values.totalQuantity || 1),
|
||||||
|
unitReward: Number(values.unitReward || 0),
|
||||||
|
totalAmount: Number(values.totalAmount || 0),
|
||||||
|
}),
|
||||||
|
'拼单配置已保存',
|
||||||
|
)
|
||||||
|
if (!succeeded) return
|
||||||
|
setSharingOrder(null)
|
||||||
|
sharingForm.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-orders'],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-summary'],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(
|
||||||
|
action: () => Promise<unknown>,
|
||||||
|
successMessage: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
message.success(successMessage)
|
||||||
|
await refreshAll()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProblem(values: { note?: string }) {
|
||||||
|
if (!problemOrder) return
|
||||||
|
const succeeded = await runAction(
|
||||||
|
() =>
|
||||||
|
markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''),
|
||||||
|
'已标记问题单',
|
||||||
|
)
|
||||||
|
if (!succeeded) return
|
||||||
|
setProblemOrder(null)
|
||||||
|
problemForm.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitResolution(values: {
|
||||||
|
action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||||
|
note?: string
|
||||||
|
}) {
|
||||||
|
if (!resolutionOrder || !values.action) return
|
||||||
|
const action = values.action
|
||||||
|
const succeeded = await runAction(
|
||||||
|
() =>
|
||||||
|
resolveAdminProblemWorkOrder(resolutionOrder.workOrderId, {
|
||||||
|
action,
|
||||||
|
note: values.note || '',
|
||||||
|
}),
|
||||||
|
'问题单已处置',
|
||||||
|
)
|
||||||
|
if (!succeeded) return
|
||||||
|
setResolutionOrder(null)
|
||||||
|
resolutionForm.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMaterial(values: { fields?: Record<string, string> }) {
|
||||||
|
if (!materialOrder) return
|
||||||
|
const succeeded = await runAction(
|
||||||
|
() =>
|
||||||
|
submitAdminWorkOrderMaterial(
|
||||||
|
materialOrder.workOrderId,
|
||||||
|
values.fields || {},
|
||||||
|
),
|
||||||
|
'订单资料已保存',
|
||||||
|
)
|
||||||
|
if (!succeeded) return
|
||||||
|
setMaterialOrder(null)
|
||||||
|
materialForm.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSourceSync(values: { platformOrderId?: string }) {
|
||||||
|
try {
|
||||||
|
const result = await syncAdminWorkerOrdersFromSource({
|
||||||
|
platformOrderId: String(values.platformOrderId || '').trim(),
|
||||||
|
})
|
||||||
|
message.success(
|
||||||
|
`同步完成,新增 ${result.data.createdCount} 个,跳过 ${result.data.skippedCount} 个`,
|
||||||
|
)
|
||||||
|
await refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '同步失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMaterialModal(row: WorkOrder) {
|
||||||
|
setMaterialOrder(row)
|
||||||
|
materialForm.setFieldsValue({ fields: getMaterialFields(row) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetailDrawer(row: WorkOrder) {
|
||||||
|
setDetailOrder(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<WorkOrder> = [
|
||||||
|
{
|
||||||
|
title: '订单',
|
||||||
|
minWidth: 300,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Link onClick={() => openDetailDrawer(row)}>
|
||||||
|
{row.productName || row.workOrderNo}
|
||||||
|
</Typography.Link>
|
||||||
|
<Space size={[6, 6]} wrap>
|
||||||
|
{row.categoryName ? <Tag>{row.categoryName}</Tag> : null}
|
||||||
|
{row.orderId ? (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
源订单 #{row.orderId}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{row.platformOrderId} · {row.workOrderNo}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
width: 110,
|
||||||
|
render: (_, row) => formatMoney(row.rewardAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '押金',
|
||||||
|
width: 110,
|
||||||
|
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '拼单',
|
||||||
|
width: 190,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.sharing?.enabled ? (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Tag color="purple">
|
||||||
|
单价 {formatMoney(row.sharing.unitReward)} / 共 {row.sharing.totalQuantity} 份
|
||||||
|
</Tag>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
已拼 {row.sharingProgress?.joinedQuantity || 0} / {row.sharing.totalQuantity} 份
|
||||||
|
{row.sharingProgress?.pendingSubmissionCount
|
||||||
|
? ` · ${row.sharingProgress.pendingSubmissionCount} 人待提交`
|
||||||
|
: ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">未启用</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 130,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Tag color={resolveStatusColor(row.status)}>
|
||||||
|
{formatStatus(row.status)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '打手',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.worker?.displayName || row.worker?.username || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '资料',
|
||||||
|
width: 160,
|
||||||
|
render: (_, row) => renderMaterialSummary(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '验收图',
|
||||||
|
width: 170,
|
||||||
|
render: (_, row) => (
|
||||||
|
<ImagePreviewList
|
||||||
|
files={getAcceptanceFiles(row)}
|
||||||
|
imageUrls={getAcceptanceImageUrls(row)}
|
||||||
|
size={44}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 400,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button onClick={() => openDetailDrawer(row)}>详情</Button>
|
||||||
|
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
|
||||||
|
<Button
|
||||||
|
icon={<TeamOutlined />}
|
||||||
|
onClick={() => openSharingConfigModal(row)}
|
||||||
|
>
|
||||||
|
拼单配置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{row.status === 'pending_material' ? (
|
||||||
|
<Button
|
||||||
|
icon={<FormOutlined />}
|
||||||
|
onClick={() => openMaterialModal(row)}
|
||||||
|
>
|
||||||
|
补资料
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{row.status === 'unassigned' ? (
|
||||||
|
<Button
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
() => publishAdminWorkOrder(row.workOrderId),
|
||||||
|
'已发布到大厅',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{row.status === 'open' ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
modal.confirm({
|
||||||
|
title: '确认下架该订单?',
|
||||||
|
content: '下架后订单会回到未分配状态,不再在抢单大厅展示。',
|
||||||
|
okText: '确认下架',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () =>
|
||||||
|
runAction(
|
||||||
|
() => unpublishAdminWorkOrder(row.workOrderId),
|
||||||
|
'订单已撤回到未分配',
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下架
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{row.status === 'pending_acceptance' ? (
|
||||||
|
<Button
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
type="primary"
|
||||||
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
() => acceptAdminWorkOrder(row.workOrderId),
|
||||||
|
'已验收通过',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
验收
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{['in_progress', 'pending_acceptance'].includes(row.status) ? (
|
||||||
|
<Button
|
||||||
|
icon={<WarningOutlined />}
|
||||||
|
danger
|
||||||
|
onClick={() => setProblemOrder(row)}
|
||||||
|
>
|
||||||
|
问题单
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{row.status === 'problem' ? (
|
||||||
|
<Button icon={<FormOutlined />} onClick={() => setResolutionOrder(row)}>
|
||||||
|
处置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="platform-panel-stack">
|
||||||
|
<Card title="从源订单同步" bordered={false}>
|
||||||
|
<Form layout="inline" onFinish={submitSourceSync}>
|
||||||
|
<Form.Item
|
||||||
|
label="平台订单号"
|
||||||
|
name="platformOrderId"
|
||||||
|
rules={[{ required: true, message: '请输入平台订单号' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="用户填写链接 A 的订单号"
|
||||||
|
style={{ width: 260 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" icon={<SyncOutlined />} htmlType="submit">
|
||||||
|
同步
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card title="生成本地接单工单 Mock" bordered={false}>
|
||||||
|
<MockWorkOrderForm onCreated={refreshAll} />
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
title="接单工单"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'pending_material', label: '待完善' },
|
||||||
|
{ value: 'unassigned', label: '未分配' },
|
||||||
|
{ value: 'open', label: '待抢单' },
|
||||||
|
{ value: 'in_progress', label: '代练中' },
|
||||||
|
{ value: 'pending_acceptance', label: '待验收' },
|
||||||
|
{ value: 'problem', label: '问题单' },
|
||||||
|
{ value: 'accepted', label: '已验收' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
]}
|
||||||
|
onChange={(nextStatus) => {
|
||||||
|
setStatus(nextStatus)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={ordersQuery.isFetching}
|
||||||
|
onClick={() => ordersQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<Table<WorkOrder>
|
||||||
|
rowKey="workOrderId"
|
||||||
|
loading={ordersQuery.isLoading}
|
||||||
|
dataSource={ordersQuery.data?.data.items || []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={buildAdminTablePagination({
|
||||||
|
current: ordersPagination?.page || page,
|
||||||
|
pageSize: ordersPagination?.pageSize || pageSize,
|
||||||
|
total: ordersPagination?.total || 0,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
scroll={{ x: 1240 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
detailOrder
|
||||||
|
? `${detailOrder.productName || '工单详情'} · ${detailOrder.workOrderNo}`
|
||||||
|
: '工单详情'
|
||||||
|
}
|
||||||
|
width={860}
|
||||||
|
open={Boolean(detailOrder)}
|
||||||
|
destroyOnHidden
|
||||||
|
onClose={() => setDetailOrder(null)}
|
||||||
|
extra={
|
||||||
|
detailOrder ? (
|
||||||
|
<Space>
|
||||||
|
<Tag color={resolveStatusColor(detailOrder.status)}>
|
||||||
|
{formatStatus(detailOrder.status)}
|
||||||
|
</Tag>
|
||||||
|
{detailOrder.orderId ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const orderId = detailOrder.orderId
|
||||||
|
setDetailOrder(null)
|
||||||
|
navigate(`/admin/orders/${orderId}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看源订单
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detailOrder ? (
|
||||||
|
<div className="worker-order-detail-stack">
|
||||||
|
<Card title="基础信息" size="small">
|
||||||
|
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||||
|
<Descriptions.Item label="工单号">
|
||||||
|
{detailOrder.workOrderNo}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="平台订单号">
|
||||||
|
{detailOrder.platformOrderId || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="源订单 ID">
|
||||||
|
{detailOrder.orderId || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单子项 ID">
|
||||||
|
{detailOrder.orderItemId || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商品">
|
||||||
|
{detailOrder.productName || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">
|
||||||
|
{detailOrder.categoryName || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={resolveStatusColor(detailOrder.status)}>
|
||||||
|
{formatStatus(detailOrder.status)}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="打手">
|
||||||
|
{detailOrder.worker?.displayName ||
|
||||||
|
detailOrder.worker?.username ||
|
||||||
|
'-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="接单金额">
|
||||||
|
{formatMoney(detailOrder.rewardAmount)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="押金">
|
||||||
|
{formatMoney(detailOrder.requiredDepositAmount)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="发布时间">
|
||||||
|
{formatAdminDateTime(detailOrder.publishedAt)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="接单时间">
|
||||||
|
{formatAdminDateTime(detailOrder.assignedAt)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="提交验收">
|
||||||
|
{formatAdminDateTime(getAcceptanceSubmittedAt(detailOrder))}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="验收通过">
|
||||||
|
{formatAdminDateTime(detailOrder.acceptedAt)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">
|
||||||
|
{formatAdminDateTime(detailOrder.createdAt)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="更新时间">
|
||||||
|
{formatAdminDateTime(detailOrder.updatedAt)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="问题备注" span={2}>
|
||||||
|
<span className="worker-order-detail-value">
|
||||||
|
{String(detailOrder.problemNote || '').trim() || '-'}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="资料信息" size="small">
|
||||||
|
{renderDetailFieldValues(
|
||||||
|
getMaterialDetailItems(detailOrder),
|
||||||
|
'当前还没有填写资料',
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="资料要求" size="small">
|
||||||
|
{renderRequirementFieldValues(detailOrder)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="验收信息" size="small">
|
||||||
|
<div className="worker-order-detail-stack">
|
||||||
|
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||||
|
<Descriptions.Item label="提交时间">
|
||||||
|
{formatAdminDateTime(getAcceptanceSubmittedAt(detailOrder))}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="验收图片">
|
||||||
|
{getAcceptanceFiles(detailOrder).length +
|
||||||
|
getAcceptanceImageUrls(detailOrder).length || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注" span={2}>
|
||||||
|
<span className="worker-order-detail-value">
|
||||||
|
{String(detailOrder.acceptance?.note || '').trim() || '-'}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{getAcceptanceFiles(detailOrder).length ||
|
||||||
|
getAcceptanceImageUrls(detailOrder).length ? (
|
||||||
|
<ImagePreviewList
|
||||||
|
files={getAcceptanceFiles(detailOrder)}
|
||||||
|
imageUrls={getAcceptanceImageUrls(detailOrder)}
|
||||||
|
size={72}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
暂无验收图
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="原始数据" size="small">
|
||||||
|
<Tabs
|
||||||
|
className="worker-order-detail-json-tabs"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'material',
|
||||||
|
label: '资料',
|
||||||
|
children: <JsonPreview value={detailOrder.material} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'requirement',
|
||||||
|
label: '需求',
|
||||||
|
children: <JsonPreview value={detailOrder.requirement} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'acceptance',
|
||||||
|
label: '验收',
|
||||||
|
children: <JsonPreview value={detailOrder.acceptance} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="标记问题单"
|
||||||
|
open={Boolean(problemOrder)}
|
||||||
|
onCancel={() => {
|
||||||
|
setProblemOrder(null)
|
||||||
|
problemForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => problemForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={problemForm} layout="vertical" onFinish={submitProblem}>
|
||||||
|
<Form.Item
|
||||||
|
label="问题备注"
|
||||||
|
name="note"
|
||||||
|
rules={[{ required: true, message: '请填写问题备注' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="处置问题单"
|
||||||
|
open={Boolean(resolutionOrder)}
|
||||||
|
onCancel={() => {
|
||||||
|
setResolutionOrder(null)
|
||||||
|
resolutionForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => resolutionForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={resolutionForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ action: 'return_to_worker' }}
|
||||||
|
onFinish={submitResolution}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="处置方式"
|
||||||
|
name="action"
|
||||||
|
rules={[{ required: true, message: '请选择处置方式' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'return_to_worker', label: '退回打手继续代练' },
|
||||||
|
{ value: 'reopen', label: '释放押金并重新放回大厅' },
|
||||||
|
{ value: 'cancel_release', label: '取消订单并退还押金' },
|
||||||
|
{ value: 'cancel_deduct', label: '取消订单并扣除押金' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="处置备注"
|
||||||
|
name="note"
|
||||||
|
rules={[{ required: true, message: '请填写处置备注' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="人工补充资料"
|
||||||
|
open={Boolean(materialOrder)}
|
||||||
|
onCancel={() => {
|
||||||
|
setMaterialOrder(null)
|
||||||
|
materialForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => materialForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={materialForm} layout="vertical" onFinish={submitMaterial}>
|
||||||
|
{getRequirementFields(materialOrder).map((field) => (
|
||||||
|
<Form.Item
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
name={['fields', field.key]}
|
||||||
|
rules={
|
||||||
|
field.required
|
||||||
|
? [{ required: true, message: `请填写${field.label}` }]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`拼单配置 · ${sharingOrder?.productName || ''}`}
|
||||||
|
open={Boolean(sharingOrder)}
|
||||||
|
onCancel={() => {
|
||||||
|
setSharingOrder(null)
|
||||||
|
sharingForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => sharingForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
{sharingOrder ? (
|
||||||
|
<Space direction="vertical" size={16} className="full-width">
|
||||||
|
<Form
|
||||||
|
form={sharingForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
enabled: false,
|
||||||
|
totalQuantity: 1,
|
||||||
|
unitReward: 0,
|
||||||
|
}}
|
||||||
|
onFinish={submitSharingConfig}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="启用拼单"
|
||||||
|
name="enabled"
|
||||||
|
valuePropName="checked"
|
||||||
|
tooltip="启用后打手可在抢单大厅按单价×数量参与拼单,未拼满时整单仍可被抢走"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('enabled') ? (
|
||||||
|
<Space size={16} wrap>
|
||||||
|
<Form.Item
|
||||||
|
label="总数量"
|
||||||
|
name="totalQuantity"
|
||||||
|
rules={[{ required: true, message: '请填写总数量' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={100000}
|
||||||
|
addonAfter="份"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingForm({
|
||||||
|
totalQuantity: Number(value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="单价"
|
||||||
|
name="unitReward"
|
||||||
|
rules={[{ required: true, message: '请填写单价' }]}
|
||||||
|
tooltip="每个数量份对应的报酬,修改后总价自动重算"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingForm({ unitReward: Number(value) || 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="拼单总价"
|
||||||
|
name="totalAmount"
|
||||||
|
rules={[{ required: true, message: '请填写总价' }]}
|
||||||
|
tooltip="总价 = 单价 × 数量,修改总价会自动反推数量"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingForm({ totalAmount: Number(value) || 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Card size="small" title="拼单明细">
|
||||||
|
{sharingQuery.isLoading ? (
|
||||||
|
<Typography.Text type="secondary">加载中...</Typography.Text>
|
||||||
|
) : (sharingQuery.data?.data.shares || []).length === 0 ? (
|
||||||
|
<Typography.Text type="secondary">暂无打手参与拼单</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Table<WorkOrderShare>
|
||||||
|
rowKey="shareId"
|
||||||
|
size="small"
|
||||||
|
dataSource={sharingQuery.data?.data.shares || []}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '打手',
|
||||||
|
render: (_, share) =>
|
||||||
|
share.worker?.displayName || share.worker?.username || '-',
|
||||||
|
},
|
||||||
|
{ title: '数量', dataIndex: 'quantity' },
|
||||||
|
{
|
||||||
|
title: '份额报酬',
|
||||||
|
render: (_, share) => formatMoney(share.shareReward),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
render: (_, share) => (
|
||||||
|
<Tag color={resolveSharingShareStatusColor(share.status)}>
|
||||||
|
{formatSharingShareStatus(share.status)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交时间',
|
||||||
|
render: (_, share) =>
|
||||||
|
share.submittedAt
|
||||||
|
? formatAdminDateTime(share.submittedAt)
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function submit(values: {
|
||||||
|
platformOrderId?: string
|
||||||
|
productName?: string
|
||||||
|
rewardAmount?: 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
|
||||||
|
layout="inline"
|
||||||
|
initialValues={{
|
||||||
|
productName: '指挥官秘钥1个',
|
||||||
|
rewardAmount: 25,
|
||||||
|
materialComplete: false,
|
||||||
|
}}
|
||||||
|
onFinish={submit}
|
||||||
|
>
|
||||||
|
<Form.Item label="订单号" name="platformOrderId">
|
||||||
|
<Input placeholder="留空自动生成" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="商品" name="productName">
|
||||||
|
<Input style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="接单金额" name="rewardAmount">
|
||||||
|
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="资料完整"
|
||||||
|
name="materialComplete"
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
FormOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
|
import {
|
||||||
|
acceptAdminWorkOrder,
|
||||||
|
createAdminMockWorkOrder,
|
||||||
|
creditAdminWorkerWallet,
|
||||||
|
deleteAdminWorkCategory,
|
||||||
|
deleteAdminWorkerLevel,
|
||||||
|
fetchAdminWorkerFinanceConfig,
|
||||||
|
fetchAdminWorkerFinanceRequests,
|
||||||
|
fetchAdminWorkCategories,
|
||||||
|
fetchAdminWorkOrderSharing,
|
||||||
|
fetchAdminWorkProductRules,
|
||||||
|
fetchAdminWorkerLevels,
|
||||||
|
fetchAdminWorkerPlatformSummary,
|
||||||
|
fetchAdminWorkerUsers,
|
||||||
|
fetchAdminWorkOrders,
|
||||||
|
markAdminWorkOrderProblem,
|
||||||
|
publishAdminWorkOrder,
|
||||||
|
reviewAdminWorkerFinanceRequest,
|
||||||
|
resolveAdminProblemWorkOrder,
|
||||||
|
reviewAdminWorkerUser,
|
||||||
|
saveAdminWorkerFinanceConfig,
|
||||||
|
saveAdminWorkCategory,
|
||||||
|
saveAdminWorkProductRule,
|
||||||
|
saveAdminWorkerLevel,
|
||||||
|
submitAdminWorkOrderMaterial,
|
||||||
|
syncAdminWorkerOrdersFromSource,
|
||||||
|
unpublishAdminWorkOrder,
|
||||||
|
updateAdminWorkOrderSharing,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
CollectField,
|
||||||
|
UploadedFile,
|
||||||
|
WorkCategory,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
|
WorkProductRule,
|
||||||
|
WorkerFinanceConfig,
|
||||||
|
WorkerFinanceRequest,
|
||||||
|
WorkerLevel,
|
||||||
|
WorkerUser,
|
||||||
|
} from '@/types/worker-platform'
|
||||||
|
import {
|
||||||
|
ADMIN_DEFAULT_PAGE_SIZE,
|
||||||
|
buildAdminTablePagination,
|
||||||
|
} from '@/utils/admin-pagination'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import { formatMoney, formatWorkerStatus } from './shared'
|
||||||
|
|
||||||
|
export default function WorkersPanel() {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||||
|
const [creditWorker, setCreditWorker] = useState<WorkerUser | null>(null)
|
||||||
|
const [levelWorker, setLevelWorker] = useState<WorkerUser | null>(null)
|
||||||
|
const [creditForm] = Form.useForm()
|
||||||
|
const [levelForm] = Form.useForm<{ levelId?: number }>()
|
||||||
|
|
||||||
|
const workersQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-workers', status, page, pageSize],
|
||||||
|
queryFn: () => fetchAdminWorkerUsers({ status, page, pageSize }),
|
||||||
|
})
|
||||||
|
const levelsQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-platform-levels'],
|
||||||
|
queryFn: () => fetchAdminWorkerLevels(),
|
||||||
|
})
|
||||||
|
const workersPagination = workersQuery.data?.data.pagination
|
||||||
|
|
||||||
|
async function refreshWorkers() {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-workers'],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-summary'],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function review(worker: WorkerUser, nextStatus: string) {
|
||||||
|
try {
|
||||||
|
await reviewAdminWorkerUser(worker.workerId, { status: nextStatus })
|
||||||
|
message.success('打手状态已更新')
|
||||||
|
await refreshWorkers()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCredit(values: { amount?: number; note?: string }) {
|
||||||
|
if (!creditWorker) return
|
||||||
|
try {
|
||||||
|
await creditAdminWorkerWallet(creditWorker.workerId, {
|
||||||
|
amount: Number(values.amount || 0),
|
||||||
|
note: values.note,
|
||||||
|
})
|
||||||
|
message.success('余额已增加')
|
||||||
|
setCreditWorker(null)
|
||||||
|
creditForm.resetFields()
|
||||||
|
await refreshWorkers()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '充值失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLevel(values: { levelId?: number }) {
|
||||||
|
if (!levelWorker) return
|
||||||
|
try {
|
||||||
|
await reviewAdminWorkerUser(levelWorker.workerId, {
|
||||||
|
status: levelWorker.status,
|
||||||
|
levelId: Number(values.levelId || 0),
|
||||||
|
reviewNote: levelWorker.reviewNote,
|
||||||
|
})
|
||||||
|
message.success('打手等级已更新')
|
||||||
|
setLevelWorker(null)
|
||||||
|
levelForm.resetFields()
|
||||||
|
await refreshWorkers()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '调整等级失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<WorkerUser> = [
|
||||||
|
{
|
||||||
|
title: '打手',
|
||||||
|
minWidth: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text strong>
|
||||||
|
{row.displayName || row.username}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">{row.username}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '等级', width: 140, render: (_, row) => row.level?.name || '-' },
|
||||||
|
{
|
||||||
|
title: '邀请人',
|
||||||
|
width: 160,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.inviter ? (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text>{row.inviter.displayName || row.inviter.username}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">{row.inviter.username}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '余额',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => formatMoney(row.wallet.availableAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '冻结',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => formatMoney(row.wallet.frozenDepositAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 130,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Tag color={row.status === 'active' ? 'green' : 'orange'}>
|
||||||
|
{formatWorkerStatus(row.status)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 340,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
{row.status !== 'active' ? (
|
||||||
|
<Button onClick={() => review(row, 'active')}>通过</Button>
|
||||||
|
) : null}
|
||||||
|
{row.status !== 'rejected' ? (
|
||||||
|
<Button onClick={() => review(row, 'rejected')}>拒绝</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setLevelWorker(row)
|
||||||
|
levelForm.setFieldsValue({
|
||||||
|
levelId: row.level?.levelId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
调等级
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreditWorker(row)}>充值</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="打手"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'pending_review', label: '待审核' },
|
||||||
|
{ value: 'active', label: '已通过' },
|
||||||
|
{ value: 'rejected', label: '已拒绝' },
|
||||||
|
{ value: 'disabled', label: '已停用' },
|
||||||
|
]}
|
||||||
|
onChange={(nextStatus) => {
|
||||||
|
setStatus(nextStatus)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={workersQuery.isFetching}
|
||||||
|
onClick={() => workersQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<Table<WorkerUser>
|
||||||
|
rowKey="workerId"
|
||||||
|
loading={workersQuery.isLoading}
|
||||||
|
dataSource={workersQuery.data?.data.items || []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={buildAdminTablePagination({
|
||||||
|
current: workersPagination?.page || page,
|
||||||
|
pageSize: workersPagination?.pageSize || pageSize,
|
||||||
|
total: workersPagination?.total || 0,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
scroll={{ x: 1000 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="调整打手等级"
|
||||||
|
open={Boolean(levelWorker)}
|
||||||
|
onCancel={() => {
|
||||||
|
setLevelWorker(null)
|
||||||
|
levelForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => levelForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={levelForm} layout="vertical" onFinish={submitLevel}>
|
||||||
|
<Form.Item label="打手">
|
||||||
|
<Typography.Text>
|
||||||
|
{levelWorker?.displayName || levelWorker?.username || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="目标等级"
|
||||||
|
name="levelId"
|
||||||
|
rules={[{ required: true, message: '请选择目标等级' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
loading={levelsQuery.isLoading}
|
||||||
|
options={(levelsQuery.data?.data.items || []).map((item) => ({
|
||||||
|
value: item.levelId,
|
||||||
|
label: `${item.name} / 最多同时 ${item.maxActiveOrders} 单`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="人工增加余额"
|
||||||
|
open={Boolean(creditWorker)}
|
||||||
|
onCancel={() => {
|
||||||
|
setCreditWorker(null)
|
||||||
|
creditForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => creditForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={creditForm} layout="vertical" onFinish={submitCredit}>
|
||||||
|
<Form.Item
|
||||||
|
label="金额"
|
||||||
|
name="amount"
|
||||||
|
rules={[{ required: true, message: '请输入金额' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={10}
|
||||||
|
addonAfter="元"
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="备注" name="note">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { Descriptions, Space, Tag, Typography } from 'antd'
|
||||||
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import type { CollectField, UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
|
export function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
||||||
|
if (!order) return []
|
||||||
|
const rawFields = Array.isArray(order.requirement?.fields)
|
||||||
|
? order.requirement.fields
|
||||||
|
: []
|
||||||
|
return rawFields
|
||||||
|
.map((item) => {
|
||||||
|
const source = asRecord(item)
|
||||||
|
const key = String(source.key || '').trim()
|
||||||
|
if (!key) return null
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: String(source.label || key).trim(),
|
||||||
|
required: source.required !== false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is CollectField => Boolean(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetailFieldItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
required: boolean
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMaterialFields(order: WorkOrder): Record<string, string> {
|
||||||
|
const material = asRecord(order.material)
|
||||||
|
const collect = asRecord(material.collect)
|
||||||
|
const fields = asRecord(collect.fields || material.fields)
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(fields).map(([key, value]) => [key, String(value || '')]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMaterialDetailItems(order: WorkOrder): DetailFieldItem[] {
|
||||||
|
const values = getMaterialFields(order)
|
||||||
|
const requiredFields = getRequirementFields(order)
|
||||||
|
const usedKeys = new Set<string>()
|
||||||
|
const items = requiredFields.map((field) => {
|
||||||
|
usedKeys.add(field.key)
|
||||||
|
return {
|
||||||
|
key: field.key,
|
||||||
|
label: field.label,
|
||||||
|
required: field.required,
|
||||||
|
value: String(values[field.key] || ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
|
if (usedKeys.has(key)) return
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
label: key,
|
||||||
|
required: false,
|
||||||
|
value: String(value || ''),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDetailFieldValues(
|
||||||
|
items: DetailFieldItem[],
|
||||||
|
emptyText: string,
|
||||||
|
) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <Typography.Text type="secondary">{emptyText}</Typography.Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Descriptions.Item
|
||||||
|
key={item.key}
|
||||||
|
label={
|
||||||
|
<Space size={6} wrap>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.required ? <Tag color="orange">必填</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="worker-order-detail-value">
|
||||||
|
{String(item.value || '').trim() || '-'}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRequirementFieldValues(order: WorkOrder) {
|
||||||
|
const fields = getRequirementFields(order)
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return <Typography.Text type="secondary">当前工单未配置资料要求</Typography.Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Descriptions.Item key={field.key} label={field.label}>
|
||||||
|
<Space size={6} wrap>
|
||||||
|
<Typography.Text code>{field.key}</Typography.Text>
|
||||||
|
<Tag color={field.required ? 'orange' : 'default'}>
|
||||||
|
{field.required ? '必填' : '选填'}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMaterialSummary(order: WorkOrder) {
|
||||||
|
const fieldLabelMap = new Map(
|
||||||
|
getRequirementFields(order).map((field) => [field.key, field.label]),
|
||||||
|
)
|
||||||
|
const fields = getMaterialFields(order)
|
||||||
|
const entries = Object.entries(fields).filter(([, value]) =>
|
||||||
|
String(value || '').trim(),
|
||||||
|
)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <Typography.Text type="secondary">-</Typography.Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
{entries.slice(0, 2).map(([key, value]) => (
|
||||||
|
<Typography.Text key={key} ellipsis>
|
||||||
|
{fieldLabelMap.get(key) || key}: {value}
|
||||||
|
</Typography.Text>
|
||||||
|
))}
|
||||||
|
{entries.length > 2 ? (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
还有 {entries.length - 2} 项
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
|
||||||
|
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAcceptanceImageUrls(row: WorkOrder): string[] {
|
||||||
|
return Array.isArray(row.acceptance?.imageUrls)
|
||||||
|
? row.acceptance.imageUrls
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAcceptanceSubmittedAt(order: WorkOrder): string | null {
|
||||||
|
const acceptanceSubmittedAt =
|
||||||
|
typeof order.acceptance?.submittedAt === 'string'
|
||||||
|
? order.acceptance.submittedAt
|
||||||
|
: null
|
||||||
|
return order.submittedAt || acceptanceSubmittedAt || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(value: number | undefined) {
|
||||||
|
return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStatus(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending_material: '待完善',
|
||||||
|
unassigned: '未分配',
|
||||||
|
open: '待抢单',
|
||||||
|
in_progress: '代练中',
|
||||||
|
pending_acceptance: '待验收',
|
||||||
|
problem: '问题单',
|
||||||
|
accepted: '已验收',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStatusColor(status: string) {
|
||||||
|
if (status === 'accepted') return 'green'
|
||||||
|
if (status === 'problem') return 'red'
|
||||||
|
if (status === 'pending_acceptance') return 'gold'
|
||||||
|
if (status === 'open') return 'blue'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWorkerStatus(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending_review: '待审核',
|
||||||
|
active: '已通过',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
disabled: '已停用',
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSharingShareStatus(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
joined: '已参与',
|
||||||
|
submitted: '已提交验收',
|
||||||
|
accepted: '已验收',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharingShareStatusColor(status: string) {
|
||||||
|
if (status === 'accepted') return 'green'
|
||||||
|
if (status === 'submitted') return 'gold'
|
||||||
|
if (status === 'cancelled') return 'default'
|
||||||
|
return 'purple'
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user