新增好友赠送验收模式支持打手资料提交加好友冷却与模板配置
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
-- 好友赠送验收模式:打手接单后提交本人资料 → 后台确认加好友 → 冷却 N 小时后才能赠送并提交验收。
|
||||
-- 发布、核销、大厅、抢单、押金、结算链路保持不变,仅验收前置流程不同。
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS acceptance_mode TEXT NOT NULL DEFAULT 'standard',
|
||||
ADD COLUMN IF NOT EXISTS gift_phase TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS booster_material_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS friend_added_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS gift_available_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS gift_cooldown_hours INTEGER NOT NULL DEFAULT 72;
|
||||
|
||||
COMMENT ON COLUMN work_orders.acceptance_mode IS '验收模式:standard 标准验收 / friend_gift 好友赠送(建单时从接单模板拷贝)';
|
||||
COMMENT ON COLUMN work_orders.gift_phase IS '好友赠送阶段:material_required 待打手提交资料 / material_submitted 待后台确认好友 / friend_countdown 冷却中;gift_ready 为 gift_available_at 到点后的实时计算值,不落库';
|
||||
COMMENT ON COLUMN work_orders.booster_material_json IS '打手本人资料:昵称/ID/区服/系统 + 主页截图';
|
||||
COMMENT ON COLUMN work_orders.friend_added_at IS '后台确认加好友时间(冷却起点)';
|
||||
COMMENT ON COLUMN work_orders.gift_available_at IS '可赠送时间 = 确认好友时间 + 冷却小时数';
|
||||
COMMENT ON COLUMN work_orders.gift_cooldown_hours IS '好友赠送冷却小时数,建单时从模板拷贝';
|
||||
|
||||
-- 后台按“可赠送”筛选待办时走该部分索引,避免全表扫描。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_gift_available
|
||||
ON work_orders (gift_available_at)
|
||||
WHERE acceptance_mode = 'friend_gift' AND status = 'in_progress';
|
||||
|
||||
ALTER TABLE work_product_rules
|
||||
ADD COLUMN IF NOT EXISTS acceptance_mode TEXT NOT NULL DEFAULT 'standard',
|
||||
ADD COLUMN IF NOT EXISTS gift_cooldown_hours INTEGER NOT NULL DEFAULT 72;
|
||||
|
||||
COMMENT ON COLUMN work_product_rules.acceptance_mode IS '验收模式:standard 标准验收 / friend_gift 好友赠送;启用 friend_gift 时不允许拼单';
|
||||
COMMENT ON COLUMN work_product_rules.gift_cooldown_hours IS '好友赠送冷却小时数(默认 72 即 3 天),建单时拷贝到工单';
|
||||
@@ -0,0 +1,63 @@
|
||||
export const WORK_ORDER_ACCEPTANCE_MODE = {
|
||||
STANDARD: 'standard',
|
||||
FRIEND_GIFT: 'friend_gift',
|
||||
} as const
|
||||
|
||||
export type WorkOrderAcceptanceMode =
|
||||
| (typeof WORK_ORDER_ACCEPTANCE_MODE)[keyof typeof WORK_ORDER_ACCEPTANCE_MODE]
|
||||
| (string & {})
|
||||
|
||||
export const DEFAULT_GIFT_COOLDOWN_HOURS = 72
|
||||
export const MAX_GIFT_COOLDOWN_HOURS = 24 * 30
|
||||
|
||||
/** 好友赠送模式的落库阶段;gift_ready 由 gift_available_at 到点后实时计算,不落库。 */
|
||||
export const WORK_ORDER_GIFT_PHASE = {
|
||||
MATERIAL_REQUIRED: 'material_required',
|
||||
MATERIAL_SUBMITTED: 'material_submitted',
|
||||
FRIEND_COUNTDOWN: 'friend_countdown',
|
||||
GIFT_READY: 'gift_ready',
|
||||
} as const
|
||||
|
||||
export type WorkOrderGiftPhase =
|
||||
| (typeof WORK_ORDER_GIFT_PHASE)[keyof typeof WORK_ORDER_GIFT_PHASE]
|
||||
| (string & {})
|
||||
|
||||
export function normalizeWorkOrderAcceptanceMode(value: unknown): WorkOrderAcceptanceMode {
|
||||
const mode = String(value || '').trim()
|
||||
return mode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
? WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
: WORK_ORDER_ACCEPTANCE_MODE.STANDARD
|
||||
}
|
||||
|
||||
export function isFriendGiftAcceptanceMode(value: unknown): boolean {
|
||||
return normalizeWorkOrderAcceptanceMode(value) === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
}
|
||||
|
||||
/** 冷却小时数收敛到 [0, 30 天],默认 3 天。 */
|
||||
export function normalizeWorkOrderGiftCooldownHours(value: unknown): number {
|
||||
const hours = Number(value)
|
||||
if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_GIFT_COOLDOWN_HOURS
|
||||
return Math.min(MAX_GIFT_COOLDOWN_HOURS, Math.max(1, Math.round(hours)))
|
||||
}
|
||||
|
||||
export type WorkOrderGiftSource = {
|
||||
acceptance_mode?: string | null
|
||||
gift_phase?: string | null
|
||||
gift_available_at?: string | null
|
||||
}
|
||||
|
||||
export function isWorkOrderGiftAvailable(source: WorkOrderGiftSource): boolean {
|
||||
if (!isFriendGiftAcceptanceMode(source.acceptance_mode)) return false
|
||||
const availableAt = Date.parse(String(source.gift_available_at || ''))
|
||||
return Number.isFinite(availableAt) && availableAt <= Date.now()
|
||||
}
|
||||
|
||||
/** 返回当前赠送阶段;冷却到点后返回实时计算的 gift_ready。 */
|
||||
export function resolveWorkOrderGiftPhase(source: WorkOrderGiftSource): string {
|
||||
if (!isFriendGiftAcceptanceMode(source.acceptance_mode)) return ''
|
||||
const phase = String(source.gift_phase || '').trim()
|
||||
if (phase === WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN && isWorkOrderGiftAvailable(source)) {
|
||||
return WORK_ORDER_GIFT_PHASE.GIFT_READY
|
||||
}
|
||||
return phase
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export * from './work-order-management-repo.js'
|
||||
export * from './work-order-grab-repo.js'
|
||||
export * from './work-order-cancel-repo.js'
|
||||
export * from './work-order-issue-repo.js'
|
||||
export * from './work-order-gift-repo.js'
|
||||
export * from './work-order-deposit-query-repo.js'
|
||||
export * from './work-order-deposit-repo.js'
|
||||
export * from './work-order-event-repo.js'
|
||||
|
||||
@@ -262,6 +262,8 @@ export type WorkProductRuleRow = {
|
||||
sharing_auto_from_order: boolean
|
||||
timeout_minutes: number
|
||||
timeout_policy: string
|
||||
acceptance_mode: string
|
||||
gift_cooldown_hours: number
|
||||
requirement_json: string | Record<string, unknown>
|
||||
match_json: string | Record<string, unknown>
|
||||
sort_order: number
|
||||
@@ -360,6 +362,15 @@ export type WorkOrderRow = {
|
||||
deadline_at: string | null
|
||||
timeout_minutes: number
|
||||
timeout_policy: string
|
||||
/** 验收模式:standard / friend_gift;friend_gift 建单时从接单模板拷贝。 */
|
||||
acceptance_mode: string
|
||||
/** 好友赠送阶段:material_required / material_submitted / friend_countdown;gift_ready 实时计算。 */
|
||||
gift_phase: string
|
||||
/** 打手本人资料(昵称/ID/区服/系统 + 主页截图),仅 friend_gift 模式使用。 */
|
||||
booster_material_json: string | Record<string, unknown>
|
||||
friend_added_at: string | null
|
||||
gift_available_at: string | null
|
||||
gift_cooldown_hours: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
/** 拼单聚合字段,由工单查询统一计算。 */
|
||||
@@ -411,6 +422,10 @@ export type ListInput = {
|
||||
excludeFilledSharing?: boolean
|
||||
/** VIP 免审核后仍在补图期限内的订单;与状态筛选取并集 */
|
||||
vipEvidencePending?: boolean
|
||||
/** 验收模式筛选:standard / friend_gift。 */
|
||||
acceptanceMode?: string
|
||||
/** 好友赠送阶段筛选:material_required / material_submitted / friend_countdown / gift_ready。 */
|
||||
giftPhase?: string
|
||||
/** 置顶订单优先展示(抢单大厅使用) */
|
||||
pinnedFirst?: boolean
|
||||
sort?: 'id_desc' | 'worker_claimed_at_desc'
|
||||
@@ -444,6 +459,8 @@ export type WorkOrderCapabilities = {
|
||||
canCancel: boolean
|
||||
canResolveProblem: boolean
|
||||
canDeduct: boolean
|
||||
canConfirmFriend: boolean
|
||||
canResetGiftFlow: boolean
|
||||
}
|
||||
|
||||
export type ProductRuleListInput = {
|
||||
@@ -510,6 +527,8 @@ export type CreateWorkOrderInput = {
|
||||
sharingUnitReward?: number
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: string
|
||||
acceptanceMode?: string
|
||||
giftCooldownHours?: number
|
||||
materialJson: string
|
||||
requirementJson: string
|
||||
now: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './share
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js'
|
||||
import { RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
|
||||
import type { WorkOrderRow } from './types.js'
|
||||
|
||||
export async function cancelWorkerWorkOrder(input: {
|
||||
@@ -47,6 +48,7 @@ export async function cancelWorkerWorkOrder(input: {
|
||||
assigned_at = NULL,
|
||||
deadline_at = NULL,
|
||||
hall_queued_at = $1,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
@@ -137,6 +139,7 @@ export async function unassignWorkOrder(input: {
|
||||
assigned_worker_id = NULL,
|
||||
assigned_at = NULL,
|
||||
deadline_at = NULL,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
@@ -228,6 +231,7 @@ export async function returnAssignedWorkOrderToHall(input: {
|
||||
deadline_at = NULL,
|
||||
published_at = $1,
|
||||
hall_queued_at = $1,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
@@ -424,6 +428,7 @@ export async function reopenCancelledWorkOrder(input: {
|
||||
problem_note = '',
|
||||
published_at = NULL,
|
||||
pinned_at = NULL,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
import type { WorkOrderRow } from './types.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
WORK_ORDER_ACCEPTANCE_MODE,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
|
||||
/**
|
||||
* 工单脱离打手(回大厅、取消指派、恢复未分配等)时重置好友赠送进度,
|
||||
* 下一个打手重新走“提交资料 → 确认好友 → 冷却”流程。
|
||||
* 标准模式工单这些字段本就为空,重置无副作用。
|
||||
*/
|
||||
export const RESET_GIFT_FLOW_SQL = `
|
||||
gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END,
|
||||
booster_material_json = '{}'::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL
|
||||
`
|
||||
|
||||
/** 打手提交本人资料(昵称/ID/区服/系统 + 主页截图),进入待确认好友阶段。 */
|
||||
export async function submitWorkOrderGiftMaterial(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
boosterMaterialJson: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
failureReason:
|
||||
| 'work_order_not_found'
|
||||
| 'work_order_mode_invalid'
|
||||
| 'work_order_owner_required'
|
||||
| 'work_order_status_invalid'
|
||||
| 'work_order_gift_phase_invalid'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = currentResult.rows[0] || null
|
||||
if (!workOrder) return { order: null, failureReason: 'work_order_not_found' }
|
||||
if (
|
||||
normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !==
|
||||
WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_mode_invalid' }
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) !== input.workerId) {
|
||||
return { order: null, failureReason: 'work_order_owner_required' }
|
||||
}
|
||||
if (!['in_progress', 'problem'].includes(workOrder.status)) {
|
||||
return { order: null, failureReason: 'work_order_status_invalid' }
|
||||
}
|
||||
if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED) {
|
||||
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
gift_phase = 'material_submitted',
|
||||
booster_material_json = $1::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL,
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
[input.boosterMaterialJson, input.now, input.workOrderId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'worker',
|
||||
actorId: String(input.workerId),
|
||||
eventType: 'gift_material_submitted',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: input.boosterMaterialJson,
|
||||
now: input.now,
|
||||
})
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 后台确认已加好友:记录冷却起点并顺延任务时限到冷却结束后再起算。 */
|
||||
export async function confirmWorkOrderFriendAdded(input: {
|
||||
workOrderId: number
|
||||
actorName?: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
failureReason:
|
||||
| 'work_order_not_found'
|
||||
| 'work_order_mode_invalid'
|
||||
| 'work_order_gift_phase_invalid'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = currentResult.rows[0] || null
|
||||
if (!workOrder) return { order: null, failureReason: 'work_order_not_found' }
|
||||
if (
|
||||
normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !==
|
||||
WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_mode_invalid' }
|
||||
}
|
||||
if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED) {
|
||||
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
||||
}
|
||||
|
||||
const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
|
||||
// 参数必须显式转型:未知类型参数与 interval 做运算时 PostgreSQL 无法唯一解析操作符。
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
gift_phase = 'friend_countdown',
|
||||
friend_added_at = $1::timestamptz,
|
||||
gift_available_at = $1::timestamptz + ($2::int * INTERVAL '1 hour'),
|
||||
deadline_at = CASE
|
||||
WHEN timeout_minutes > 0
|
||||
THEN $1::timestamptz + ($2::int * INTERVAL '1 hour') + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at = $1::timestamptz
|
||||
WHERE id = $3
|
||||
`,
|
||||
[input.now, cooldownHours, input.workOrderId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'friend_confirmed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
cooldownHours,
|
||||
friendAddedAt: input.now,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 后台重置打手资料(资料有误或误确认好友):清空好友状态并让打手重新提交。 */
|
||||
export async function resetWorkOrderGiftMaterial(input: {
|
||||
workOrderId: number
|
||||
note?: string
|
||||
actorName?: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
failureReason:
|
||||
| 'work_order_not_found'
|
||||
| 'work_order_mode_invalid'
|
||||
| 'work_order_status_invalid'
|
||||
| 'work_order_gift_phase_invalid'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = currentResult.rows[0] || null
|
||||
if (!workOrder) return { order: null, failureReason: 'work_order_not_found' }
|
||||
if (
|
||||
normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !==
|
||||
WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_mode_invalid' }
|
||||
}
|
||||
if (!['in_progress', 'problem'].includes(workOrder.status)) {
|
||||
return { order: null, failureReason: 'work_order_status_invalid' }
|
||||
}
|
||||
if (
|
||||
workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED &&
|
||||
workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
gift_phase = 'material_required',
|
||||
booster_material_json = '{}'::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL,
|
||||
deadline_at = CASE
|
||||
WHEN timeout_minutes > 0
|
||||
THEN $1::timestamptz + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at = $1::timestamptz
|
||||
WHERE id = $2
|
||||
`,
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'gift_material_reset',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
previousPhase: workOrder.gift_phase,
|
||||
note: String(input.note || '').trim(),
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -60,6 +60,10 @@ export async function grabWorkOrder(input: {
|
||||
last_assigned_worker_id = $1,
|
||||
assigned_at = $2,
|
||||
deadline_at = $3,
|
||||
gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END,
|
||||
booster_material_json = '{}'::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL,
|
||||
updated_at = $2
|
||||
WHERE id = $4
|
||||
AND status = 'open'
|
||||
@@ -180,6 +184,10 @@ export async function assignWorkOrderToWorker(input: {
|
||||
last_assigned_worker_id = $1,
|
||||
assigned_at = $2,
|
||||
deadline_at = $3,
|
||||
gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END,
|
||||
booster_material_json = '{}'::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL,
|
||||
updated_at = $2
|
||||
WHERE id = $4
|
||||
`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './share
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js'
|
||||
import { RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
|
||||
import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js'
|
||||
|
||||
export async function resolveProblemWorkOrder(input: {
|
||||
@@ -100,11 +101,34 @@ export async function resolveProblemWorkOrder(input: {
|
||||
submitted_at = CASE WHEN $3 THEN NULL ELSE submitted_at END,
|
||||
problem_note = CASE WHEN $2 THEN '' ELSE problem_note END,
|
||||
deadline_at = CASE
|
||||
WHEN $1 = 'in_progress' AND timeout_minutes > 0
|
||||
THEN NOW() + (timeout_minutes * INTERVAL '1 minute')
|
||||
WHEN $1 = 'in_progress' AND timeout_minutes > 0 THEN
|
||||
CASE
|
||||
WHEN acceptance_mode = 'friend_gift'
|
||||
AND gift_available_at IS NOT NULL
|
||||
AND gift_available_at > NOW()
|
||||
THEN gift_available_at + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NOW() + (timeout_minutes * INTERVAL '1 minute')
|
||||
END
|
||||
WHEN $1 = 'open' THEN NULL
|
||||
ELSE deadline_at
|
||||
END,
|
||||
gift_phase = CASE
|
||||
WHEN $1 = 'open' OR $1 = 'cancelled'
|
||||
THEN CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END
|
||||
ELSE gift_phase
|
||||
END,
|
||||
booster_material_json = CASE
|
||||
WHEN $1 = 'open' OR $1 = 'cancelled' THEN '{}'::jsonb
|
||||
ELSE booster_material_json
|
||||
END,
|
||||
friend_added_at = CASE
|
||||
WHEN $1 = 'open' OR $1 = 'cancelled' THEN NULL
|
||||
ELSE friend_added_at
|
||||
END,
|
||||
gift_available_at = CASE
|
||||
WHEN $1 = 'open' OR $1 = 'cancelled' THEN NULL
|
||||
ELSE gift_available_at
|
||||
END,
|
||||
updated_at = $4
|
||||
WHERE id = $5
|
||||
`,
|
||||
@@ -237,6 +261,7 @@ export async function settleOverdueWorkOrder(input: {
|
||||
problem_note = CASE WHEN $1 = 'open' THEN '' ELSE problem_note END,
|
||||
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
|
||||
hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { toJsonString, toPositiveInteger } from './shared.js'
|
||||
import { getWorkOrderById } from './work-order-query-repo.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
WORK_ORDER_ACCEPTANCE_MODE,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import type { CreateWorkOrderInput, WorkOrderRow } from './types.js'
|
||||
|
||||
export async function createWorkOrder(input: CreateWorkOrderInput): Promise<WorkOrderRow | null> {
|
||||
const acceptanceMode = normalizeWorkOrderAcceptanceMode(input.acceptanceMode)
|
||||
const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
const result = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO work_orders (
|
||||
@@ -11,10 +19,13 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
product_name, category_id, status, reward_amount, required_deposit_amount,
|
||||
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
|
||||
sharing_unit_reward, timeout_minutes, timeout_policy,
|
||||
acceptance_mode, gift_phase, gift_cooldown_hours,
|
||||
material_json, requirement_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17, $18::jsonb, $19::jsonb, $20, $21
|
||||
$12, $13, $14, $15, $16, $17,
|
||||
$18, $19, $20,
|
||||
$21::jsonb, $22::jsonb, $23, $24
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
@@ -36,6 +47,9 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
toPositiveInteger(input.sharingUnitReward, 0),
|
||||
toPositiveInteger(input.timeoutMinutes, 0),
|
||||
String(input.timeoutPolicy || 'reopen').trim() || 'reopen',
|
||||
acceptanceMode,
|
||||
isFriendGift ? WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED : '',
|
||||
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
|
||||
input.materialJson,
|
||||
input.requirementJson,
|
||||
input.now,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import type { ListInput, WorkOrderRow, WorkOrderStatistics } from './types.js'
|
||||
import type { PoolClient } from 'pg'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
export const WORK_ORDER_SELECT = `
|
||||
SELECT
|
||||
wo.*,
|
||||
@@ -167,6 +171,8 @@ export async function listWorkOrders({
|
||||
visibleAfterIso = '',
|
||||
excludeFilledSharing = false,
|
||||
vipEvidencePending = false,
|
||||
acceptanceMode = '',
|
||||
giftPhase = '',
|
||||
useOperationalStatus = false,
|
||||
pinnedFirst = false,
|
||||
sort = 'id_desc',
|
||||
@@ -186,6 +192,8 @@ export async function listWorkOrders({
|
||||
visibleAfterIso,
|
||||
excludeFilledSharing,
|
||||
vipEvidencePending,
|
||||
acceptanceMode,
|
||||
giftPhase,
|
||||
useOperationalStatus,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
@@ -382,7 +390,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
}))
|
||||
}
|
||||
|
||||
/** 查询已超过接单时限、仍待处置的工单。 */
|
||||
/** 查询已超过接单时限、仍待处置的工单。好友赠送单在“待确认好友”阶段等待后台操作,不计打手超时。 */
|
||||
export async function listOverdueWorkOrders({
|
||||
limit = 50,
|
||||
workerId = 0,
|
||||
@@ -399,6 +407,7 @@ export async function listOverdueWorkOrders({
|
||||
WHERE wo.status = 'in_progress'
|
||||
AND wo.deadline_at IS NOT NULL
|
||||
AND wo.deadline_at < NOW()
|
||||
AND NOT (wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = 'material_submitted')
|
||||
${workerClause}
|
||||
ORDER BY wo.deadline_at ASC
|
||||
LIMIT $${params.length}`,
|
||||
@@ -522,6 +531,8 @@ export function buildWorkOrderWhere({
|
||||
visibleAfterIso = '',
|
||||
excludeFilledSharing = false,
|
||||
vipEvidencePending = false,
|
||||
acceptanceMode = '',
|
||||
giftPhase = '',
|
||||
useOperationalStatus = false,
|
||||
createdFrom = '',
|
||||
createdTo = '',
|
||||
@@ -685,6 +696,25 @@ export function buildWorkOrderWhere({
|
||||
params.push(categoryId)
|
||||
filters.push(`wo.category_id = $${params.length}`)
|
||||
}
|
||||
if (acceptanceMode) {
|
||||
params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode))
|
||||
filters.push(`wo.acceptance_mode = $${params.length}`)
|
||||
}
|
||||
if (giftPhase) {
|
||||
const normalizedGiftPhase = String(giftPhase).trim()
|
||||
if (normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.GIFT_READY) {
|
||||
// gift_ready 是冷却到点后的实时阶段,由时间条件计算而不是落库状态。
|
||||
filters.push(`(
|
||||
wo.acceptance_mode = 'friend_gift'
|
||||
AND wo.gift_phase = 'friend_countdown'
|
||||
AND wo.gift_available_at IS NOT NULL
|
||||
AND wo.gift_available_at <= NOW()
|
||||
)`)
|
||||
} else if (Object.values(WORK_ORDER_GIFT_PHASE).includes(normalizedGiftPhase as never)) {
|
||||
params.push(normalizedGiftPhase)
|
||||
filters.push(`wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = $${params.length}`)
|
||||
}
|
||||
}
|
||||
if (hallCandidateLimit > 0) {
|
||||
params.push(hallCandidateLimit)
|
||||
filters.push(`wo.id IN (${buildHallCandidateIdsSql(`$${params.length}`)})`)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { toPositiveInteger } from './shared.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import type {
|
||||
ProductRuleListInput,
|
||||
WorkOrderRow,
|
||||
@@ -336,6 +340,8 @@ export async function upsertWorkProductRule(input: {
|
||||
sharingAutoFromOrder?: boolean
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: string
|
||||
acceptanceMode?: string
|
||||
giftCooldownHours?: number
|
||||
requirementJson: string
|
||||
matchJson?: string
|
||||
sortOrder: number
|
||||
@@ -350,6 +356,7 @@ export async function upsertWorkProductRule(input: {
|
||||
required_deposit_amount, deposit_threshold_amount,
|
||||
sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order,
|
||||
timeout_minutes, timeout_policy,
|
||||
acceptance_mode, gift_cooldown_hours,
|
||||
requirement_json, match_json,
|
||||
sort_order, created_at, updated_at
|
||||
) VALUES (
|
||||
@@ -359,8 +366,9 @@ export async function upsertWorkProductRule(input: {
|
||||
$13, $14,
|
||||
$15, $16, $17, $18,
|
||||
$19, $20,
|
||||
$21::jsonb, $22::jsonb,
|
||||
$23, $24, $25
|
||||
$21, $22,
|
||||
$23::jsonb, $24::jsonb,
|
||||
$25, $26, $27
|
||||
)
|
||||
ON CONFLICT (rule_key) DO UPDATE
|
||||
SET
|
||||
@@ -383,6 +391,8 @@ export async function upsertWorkProductRule(input: {
|
||||
sharing_auto_from_order = EXCLUDED.sharing_auto_from_order,
|
||||
timeout_minutes = EXCLUDED.timeout_minutes,
|
||||
timeout_policy = EXCLUDED.timeout_policy,
|
||||
acceptance_mode = EXCLUDED.acceptance_mode,
|
||||
gift_cooldown_hours = EXCLUDED.gift_cooldown_hours,
|
||||
requirement_json = EXCLUDED.requirement_json,
|
||||
match_json = EXCLUDED.match_json,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
@@ -410,6 +420,8 @@ export async function upsertWorkProductRule(input: {
|
||||
input.sharingAutoFromOrder === true,
|
||||
toPositiveInteger(input.timeoutMinutes, 0),
|
||||
String(input.timeoutPolicy || 'reopen').trim(),
|
||||
normalizeWorkOrderAcceptanceMode(input.acceptanceMode),
|
||||
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
|
||||
input.requirementJson,
|
||||
input.matchJson || '{}',
|
||||
input.sortOrder,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
acceptAdminWorkOrders,
|
||||
assignAdminWorkOrderToWorker,
|
||||
cancelAdminWorkOrder,
|
||||
confirmAdminWorkOrderFriendAdded,
|
||||
createAdminMockWorkOrder,
|
||||
deleteAdminWorkOrder,
|
||||
getAdminWorkOrderEvents,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
publishAdminWorkOrder,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
reprocessAdminKuaishouSendCodeWorkOrders,
|
||||
resetAdminWorkOrderGiftMaterial,
|
||||
submitAdminWorkOrderMaterial,
|
||||
unassignAdminWorkOrder,
|
||||
unpublishAdminWorkOrder,
|
||||
@@ -292,6 +294,53 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/confirm-friend',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
confirmAdminWorkOrderFriendAdded(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已确认加好友,赠送冷却开始',
|
||||
errorMessage: '确认加好友失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/confirm-friend]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_friend_confirmed',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/reset-gift-material',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
resetAdminWorkOrderGiftMaterial(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已重置打手资料,等待打手重新提交',
|
||||
errorMessage: '重置打手资料失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/reset-gift-material]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_gift_material_reset',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/orders/:workOrderId/sharing',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
submitWorkerCancelRequest,
|
||||
submitWorkerOrderAcceptance,
|
||||
submitWorkerOrderFeedback,
|
||||
submitWorkerOrderGiftMaterial,
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
} from '../../services/worker-platform/index.js'
|
||||
import { getRequiredWorkerSession, requireActiveWorker } from './session.js'
|
||||
@@ -201,6 +202,24 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:workOrderId/gift-material',
|
||||
requireActiveWorker,
|
||||
createRouteHandler(
|
||||
(req) =>
|
||||
submitWorkerOrderGiftMaterial(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
getRequiredWorkerSession(req),
|
||||
),
|
||||
{
|
||||
successMessage: '好友赠送资料已提交,等待后台确认加好友',
|
||||
errorMessage: '提交好友赠送资料失败',
|
||||
scope: '[worker/orders/:workOrderId/gift-material]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:workOrderId/remind-acceptance',
|
||||
requireActiveWorker,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import { createWorkOrder, createWorkOrderEvent } from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -78,6 +82,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
depositThresholdAmount,
|
||||
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
|
||||
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
|
||||
acceptanceMode: normalizeWorkOrderAcceptanceMode(payload.acceptanceMode),
|
||||
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(payload.giftCooldownHours),
|
||||
materialJson: JSON.stringify(material),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
now,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
confirmWorkOrderFriendAdded,
|
||||
resetWorkOrderGiftMaterial,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { mapWorkOrderAdmin } from './mappers.js'
|
||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
|
||||
/** 后台核对打手资料并添加游戏好友后确认,开始 N 小时赠送冷却。 */
|
||||
export async function confirmAdminWorkOrderFriendAdded(
|
||||
workOrderId: number | string,
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const { order, failureReason } = await confirmWorkOrderFriendAdded({
|
||||
workOrderId: Number(workOrder.id),
|
||||
actorName,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (failureReason === 'work_order_gift_phase_invalid') {
|
||||
throw createHttpError('当前阶段不能确认加好友,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_friend_confirm_phase_invalid',
|
||||
})
|
||||
}
|
||||
if (!order) {
|
||||
throw createHttpError('确认加好友失败,订单可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_friend_confirm_failed',
|
||||
})
|
||||
}
|
||||
const workerId = Number(order.assigned_worker_id || 0)
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(order.id),
|
||||
workerIds: workerId > 0 ? [workerId] : [],
|
||||
})
|
||||
return { order: mapWorkOrderAdmin(order) }
|
||||
}
|
||||
|
||||
/** 打手资料有误或误确认好友时,重置资料让打手重新提交(冷却与好友时间清零)。 */
|
||||
export async function resetAdminWorkOrderGiftMaterial(
|
||||
workOrderId: number | string,
|
||||
payload: { note?: unknown } = {},
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const note = String(payload.note || '').trim()
|
||||
const { order, failureReason } = await resetWorkOrderGiftMaterial({
|
||||
workOrderId: Number(workOrder.id),
|
||||
note,
|
||||
actorName,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (failureReason === 'work_order_gift_phase_invalid') {
|
||||
throw createHttpError('当前阶段没有可重置的打手资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_reset_phase_invalid',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'work_order_status_invalid') {
|
||||
throw createHttpError('当前订单状态不能重置资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_reset_status_invalid',
|
||||
})
|
||||
}
|
||||
if (!order) {
|
||||
throw createHttpError('重置打手资料失败,订单可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_reset_failed',
|
||||
})
|
||||
}
|
||||
const workerId = Number(order.assigned_worker_id || 0)
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(order.id),
|
||||
workerIds: workerId > 0 ? [workerId] : [],
|
||||
})
|
||||
return { order: mapWorkOrderAdmin(order) }
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false)
|
||||
const createdFrom = normalizeDateString(query.createdFrom)
|
||||
const createdTo = normalizeDateString(query.createdTo)
|
||||
const acceptanceMode = String(query.acceptanceMode || '').trim()
|
||||
const giftPhase = String(query.giftPhase || '').trim()
|
||||
const filters = {
|
||||
statuses,
|
||||
workOrderId,
|
||||
@@ -45,6 +47,8 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
workerSharingId: workerId,
|
||||
workerLevelIds,
|
||||
vipEvidencePending,
|
||||
...(acceptanceMode ? { acceptanceMode } : {}),
|
||||
...(giftPhase ? { giftPhase } : {}),
|
||||
useOperationalStatus: true,
|
||||
...(createdFrom ? { createdFrom } : {}),
|
||||
...(createdTo ? { createdTo } : {}),
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
upsertWorkProductRule,
|
||||
} from '../../repositories/worker-platform/work-product-rule-repo.js'
|
||||
import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
WORK_ORDER_ACCEPTANCE_MODE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
@@ -257,6 +262,17 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
? 0
|
||||
: normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0)
|
||||
const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false)
|
||||
const acceptanceMode = normalizeWorkOrderAcceptanceMode(
|
||||
hasOwnPayload(payload, 'acceptanceMode') || hasOwnPayload(payload, 'acceptance_mode')
|
||||
? payload.acceptanceMode || payload.acceptance_mode
|
||||
: existingRule?.acceptance_mode,
|
||||
)
|
||||
if (acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT && sharingEnabled) {
|
||||
throw createHttpError('好友赠送验收模式不支持拼单,请先关闭拼单', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_rule_gift_sharing_conflict',
|
||||
})
|
||||
}
|
||||
const hasManualSharingConfig =
|
||||
hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) ||
|
||||
hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) ||
|
||||
@@ -351,6 +367,13 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
sharingAutoFromOrder,
|
||||
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
|
||||
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
|
||||
acceptanceMode,
|
||||
giftCooldownHours:
|
||||
hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours')
|
||||
? normalizeWorkOrderGiftCooldownHours(
|
||||
payload.giftCooldownHours ?? payload.gift_cooldown_hours,
|
||||
)
|
||||
: normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
matchJson: JSON.stringify(match),
|
||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||
|
||||
@@ -15,6 +15,7 @@ export * from './worker-order-timeout-service.js'
|
||||
export * from './worker-order-realtime-service.js'
|
||||
export * from './worker-collect-service.js'
|
||||
export * from './worker-acceptance-service.js'
|
||||
export * from './worker-gift-material-service.js'
|
||||
export * from './worker-my-order-service.js'
|
||||
export * from './worker-hall-action-service.js'
|
||||
export * from './worker-platform-defaults.js'
|
||||
@@ -33,6 +34,7 @@ export * from './admin-worker-cancel-service.js'
|
||||
export * from './admin-work-order-sharing-service.js'
|
||||
export * from './admin-work-order-realtime-service.js'
|
||||
export * from './admin-work-order-material-service.js'
|
||||
export * from './admin-work-order-gift-service.js'
|
||||
export * from './admin-mock-work-order-service.js'
|
||||
export * from './admin-work-order-management-service.js'
|
||||
export * from './admin-worker-platform-summary-service.js'
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
isFriendGiftAcceptanceMode,
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
createWorkProductMatchLog,
|
||||
createWorkOrder,
|
||||
@@ -95,10 +100,9 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
|
||||
})
|
||||
const sharingEnabled = resolveWorkOrderSharingEnabled(
|
||||
rule.sharing_enabled === true,
|
||||
sharingTotalQuantity,
|
||||
)
|
||||
const sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode)
|
||||
? false
|
||||
: resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity)
|
||||
const now = nowIso()
|
||||
const materialComplete = fields.every((field) => !field.required)
|
||||
const workOrder = await createWorkOrder({
|
||||
@@ -119,6 +123,8 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
sharingUnitReward,
|
||||
timeoutMinutes: Number(rule.timeout_minutes || 0),
|
||||
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
|
||||
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
|
||||
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
|
||||
materialJson: JSON.stringify({
|
||||
source: {
|
||||
orderId: Number(order.id),
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
isFriendGiftAcceptanceMode,
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
resolveWorkOrderGiftPhase,
|
||||
WORK_ORDER_ACCEPTANCE_MODE,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
type WorkCategoryRow,
|
||||
type WorkOrderCapabilities,
|
||||
@@ -49,6 +57,20 @@ export function mapWorkOrderMaterial(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取好友赠送模式的打手本人资料;主页截图访问链接需要读取时重签。 */
|
||||
export function mapWorkOrderBoosterMaterial(value: unknown) {
|
||||
const material = safeParseJson(value)
|
||||
return {
|
||||
fields:
|
||||
material.fields && typeof material.fields === 'object' && !Array.isArray(material.fields)
|
||||
? (material.fields as Record<string, string>)
|
||||
: {},
|
||||
screenshots: normalizeUploadedFiles(material.screenshots).map(refreshUploadedFileUrls),
|
||||
note: String(material.note || ''),
|
||||
submittedAt: String(material.submittedAt || ''),
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一计算后台使用的工单业务状态,避免拼单订单继续直接暴露母工单 open。 */
|
||||
export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string {
|
||||
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
|
||||
@@ -100,6 +122,9 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
||||
const sharingFilled =
|
||||
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
|
||||
const status = resolveWorkOrderOperationalStatus(workOrder)
|
||||
const acceptanceMode = normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode)
|
||||
const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
const giftPhase = String(workOrder.gift_phase || '').trim()
|
||||
const hallAvailable =
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity)
|
||||
@@ -142,6 +167,18 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
||||
canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id),
|
||||
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
||||
canConfirmFriend:
|
||||
isFriendGift &&
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||
giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED,
|
||||
canResetGiftFlow:
|
||||
isFriendGift &&
|
||||
[WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(
|
||||
workOrder.status as never,
|
||||
) &&
|
||||
[WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED, WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN].includes(
|
||||
giftPhase as never,
|
||||
),
|
||||
}
|
||||
return {
|
||||
workOrderId: Number(workOrder.id),
|
||||
@@ -155,6 +192,19 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
||||
status,
|
||||
rawStatus: workOrder.status,
|
||||
hallAvailable,
|
||||
acceptanceMode,
|
||||
...(isFriendGift
|
||||
? {
|
||||
gift: {
|
||||
phase: resolveWorkOrderGiftPhase(workOrder),
|
||||
rawPhase: giftPhase,
|
||||
cooldownHours: normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours),
|
||||
friendAddedAt: workOrder.friend_added_at,
|
||||
giftAvailableAt: workOrder.gift_available_at,
|
||||
boosterMaterial: mapWorkOrderBoosterMaterial(workOrder.booster_material_json),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
sharingProgress: {
|
||||
joinedQuantity: activeShareQuantity,
|
||||
pendingSubmissionCount: pendingShareCount,
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
type WorkProductRuleMappingRow,
|
||||
type WorkProductRuleRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
@@ -45,6 +49,8 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined)
|
||||
},
|
||||
timeoutMinutes: Number(rule.timeout_minutes || 0),
|
||||
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
|
||||
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
|
||||
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
|
||||
requirement: {
|
||||
fields: normalizeRequirementFields(requirement.fields),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
isFriendGiftAcceptanceMode,
|
||||
isWorkOrderGiftAvailable,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
acceptWorkOrderAndSettle,
|
||||
acceptWorkOrderShareAndSettle,
|
||||
@@ -61,13 +66,20 @@ export async function submitWorkerOrderAcceptance(
|
||||
if (sharingShare) {
|
||||
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
|
||||
}
|
||||
const canAutoAcceptWithoutEvidence = canWorkerAutoAcceptWholeOrder({
|
||||
worker,
|
||||
hasPersonalSharingShare: false,
|
||||
})
|
||||
const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
|
||||
? false
|
||||
: canWorkerAutoAcceptWholeOrder({
|
||||
worker,
|
||||
hasPersonalSharingShare: false,
|
||||
})
|
||||
const now = nowIso()
|
||||
// 好友赠送单在“待确认好友”阶段是等后台操作,等待期不计打手超时。
|
||||
const awaitingFriendConfirm =
|
||||
isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
|
||||
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED
|
||||
if (
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||
!awaitingFriendConfirm &&
|
||||
workOrder.deadline_at &&
|
||||
new Date(workOrder.deadline_at).getTime() <= Date.now()
|
||||
) {
|
||||
@@ -95,6 +107,18 @@ export async function submitWorkerOrderAcceptance(
|
||||
errorCode: 'work_order_submit_status_invalid',
|
||||
})
|
||||
}
|
||||
// 好友赠送模式:必须等后台确认加好友且冷却结束后,才能提交赠送凭证。
|
||||
if (isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) {
|
||||
if (
|
||||
workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN ||
|
||||
!isWorkOrderGiftAvailable(workOrder)
|
||||
) {
|
||||
throw createHttpError('好友赠送冷却未结束,暂不能提交验收', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_cooldown_active',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { files, imageUrls, note } = resolveAcceptanceImages(
|
||||
payload,
|
||||
@@ -141,7 +165,11 @@ export async function submitWorkerOrderAcceptance(
|
||||
}),
|
||||
now,
|
||||
})
|
||||
if (worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence) {
|
||||
// 好友赠送模式必须由后台人工验收赠送凭证,内部打手与 VIP 免审核直通均不适用。
|
||||
if (
|
||||
!isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
|
||||
(worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence)
|
||||
) {
|
||||
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
|
||||
workOrderId: workOrder.id,
|
||||
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js'
|
||||
import { submitWorkOrderGiftMaterial } from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
mapWorkOrderForWorker,
|
||||
normalizeUploadedFiles,
|
||||
resolveWorkerPermissions,
|
||||
} from './mappers.js'
|
||||
import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
|
||||
import {
|
||||
getRequiredWorkOrder,
|
||||
getRequiredWorker,
|
||||
requireActiveWorkerSession,
|
||||
type WorkerSession,
|
||||
} from './worker-session-context-service.js'
|
||||
|
||||
/** 打手本人资料的固定字段:昵称/ID/区服必填,系统选填(安卓/苹果)。 */
|
||||
const BOOSTER_MATERIAL_REQUIRED_FIELDS = [
|
||||
{ key: 'gameNickname', label: '游戏昵称' },
|
||||
{ key: 'gameId', label: '游戏ID' },
|
||||
{ key: 'serverZone', label: '区服' },
|
||||
] as const
|
||||
|
||||
/** 好友赠送模式:打手接单后提交本人游戏资料与主页截图,等待后台核对并添加好友。 */
|
||||
export async function submitWorkerOrderGiftMaterial(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
session: WorkerSession,
|
||||
) {
|
||||
requireActiveWorkerSession(session)
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
if (!isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) {
|
||||
throw createHttpError('该订单不需要提交好友赠送资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_mode_invalid',
|
||||
})
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
|
||||
throw createHttpError('只能操作自己的订单', {
|
||||
statusCode: 403,
|
||||
errorCode: 'work_order_owner_required',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fieldsPayload =
|
||||
payload.fields && typeof payload.fields === 'object' && !Array.isArray(payload.fields)
|
||||
? (payload.fields as Record<string, unknown>)
|
||||
: {}
|
||||
const fields: Record<string, string> = {}
|
||||
for (const field of BOOSTER_MATERIAL_REQUIRED_FIELDS) {
|
||||
const value = String(fieldsPayload[field.key] ?? '').trim()
|
||||
if (!value) {
|
||||
throw createHttpError(`请填写${field.label}`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_gift_material_field_required',
|
||||
})
|
||||
}
|
||||
fields[field.key] = value
|
||||
}
|
||||
const system = String(fieldsPayload.system ?? '').trim()
|
||||
if (system) fields.system = system
|
||||
|
||||
const note = String(payload.note || '').trim()
|
||||
if (note.length > 200) {
|
||||
throw createHttpError('备注不能超过 200 个字符', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_gift_material_note_too_long',
|
||||
})
|
||||
}
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots)
|
||||
if (screenshots.length === 0) {
|
||||
throw createHttpError('请上传主页截图', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_gift_material_screenshot_required',
|
||||
})
|
||||
}
|
||||
|
||||
const { order, failureReason } = await submitWorkOrderGiftMaterial({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
boosterMaterialJson: JSON.stringify({
|
||||
fields,
|
||||
screenshots,
|
||||
note,
|
||||
submittedAt: now,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
if (failureReason === 'work_order_gift_phase_invalid') {
|
||||
throw createHttpError('当前阶段不能提交资料,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_phase_invalid',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'work_order_status_invalid') {
|
||||
throw createHttpError('当前订单状态不能提交资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_status_invalid',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'work_order_owner_required') {
|
||||
throw createHttpError('只能操作自己的订单', {
|
||||
statusCode: 403,
|
||||
errorCode: 'work_order_owner_required',
|
||||
})
|
||||
}
|
||||
if (!order) {
|
||||
throw createHttpError('提交好友赠送资料失败,请稍后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_material_submit_failed',
|
||||
})
|
||||
}
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
workerIds: [Number(session.workerId)],
|
||||
})
|
||||
return {
|
||||
order: mapWorkOrderForWorker(order, resolveWorkerPermissions(worker)),
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,8 @@ export default function ProductRulesPanel() {
|
||||
: undefined,
|
||||
timeoutMinutes: rule.timeoutMinutes || 0,
|
||||
timeoutPolicy: rule.timeoutPolicy || 'reopen',
|
||||
acceptanceMode: rule.acceptanceMode || 'standard',
|
||||
giftCooldownHours: rule.giftCooldownHours || 72,
|
||||
fieldsText: formatRuleFields(rule.requirement?.fields || []),
|
||||
sortOrder: rule.sortOrder,
|
||||
enabled: rule.enabled,
|
||||
@@ -244,6 +246,8 @@ export default function ProductRulesPanel() {
|
||||
sharingTotalAmount?: number
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: string
|
||||
acceptanceMode?: string
|
||||
giftCooldownHours?: number
|
||||
fieldsText?: string
|
||||
enabled?: boolean
|
||||
autoCreate?: boolean
|
||||
@@ -251,6 +255,11 @@ export default function ProductRulesPanel() {
|
||||
}) {
|
||||
try {
|
||||
const unitMode = values.pricingMode === 'unit'
|
||||
const isFriendGift = values.acceptanceMode === 'friend_gift'
|
||||
if (isFriendGift && values.sharingEnabled === true) {
|
||||
message.error('好友赠送验收模式不支持拼单,请先关闭拼单')
|
||||
return
|
||||
}
|
||||
const hasManualSharingOverride =
|
||||
form.isFieldTouched('sharingTotalQuantity') || form.isFieldTouched('sharingUnitReward')
|
||||
? Number(values.sharingTotalQuantity || 0) > 0 ||
|
||||
@@ -262,6 +271,8 @@ export default function ProductRulesPanel() {
|
||||
ruleId: editingRule?.ruleId,
|
||||
ruleKey: editingRule?.ruleKey || '',
|
||||
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
|
||||
acceptanceMode: isFriendGift ? 'friend_gift' : 'standard',
|
||||
sharingEnabled: isFriendGift ? false : values.sharingEnabled === true,
|
||||
sharingAutoFromOrder:
|
||||
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
|
||||
})
|
||||
@@ -638,6 +649,11 @@ export default function ProductRulesPanel() {
|
||||
拼单
|
||||
</Tag>
|
||||
) : null}
|
||||
{row.acceptanceMode === 'friend_gift' ? (
|
||||
<Tag color="orange" style={{ fontSize: 10, margin: 0, padding: '0 3px' }}>
|
||||
好友赠送
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
@@ -728,6 +744,8 @@ export default function ProductRulesPanel() {
|
||||
enabled: true,
|
||||
autoCreate: true,
|
||||
sharingEnabled: false,
|
||||
acceptanceMode: 'standard',
|
||||
giftCooldownHours: 72,
|
||||
fieldsText:
|
||||
'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区',
|
||||
}}
|
||||
@@ -794,6 +812,53 @@ export default function ProductRulesPanel() {
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 验收模式:好友赠送模式与拼单互斥 */}
|
||||
<div className="form-grid-3col">
|
||||
<Form.Item
|
||||
label="验收模式"
|
||||
name="acceptanceMode"
|
||||
extra="好友赠送:打手接单后先提交本人资料,后台加好友并冷却后才能赠送交付"
|
||||
>
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
className="full-width"
|
||||
options={[
|
||||
{ value: 'standard', label: '标准验收' },
|
||||
{ value: 'friend_gift', label: '好友赠送' },
|
||||
]}
|
||||
onChange={(event) => {
|
||||
if (event.target.value === 'friend_gift') {
|
||||
form.setFieldsValue({ sharingEnabled: false })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('acceptanceMode') === 'friend_gift' ? (
|
||||
<Form.Item
|
||||
label="赠送冷却"
|
||||
name="giftCooldownHours"
|
||||
rules={[{ required: true, message: '请填写冷却小时数' }]}
|
||||
extra="确认加好友后需冷却该时长才能赠送(默认 72 小时 = 3 天)"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={720}
|
||||
step={1}
|
||||
addonAfter="小时"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label={<span />} />
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 超时策略 + 来源 + 平台 */}
|
||||
<div className="form-grid-3col">
|
||||
<Form.Item label="超时策略" name="timeoutPolicy">
|
||||
@@ -825,6 +890,7 @@ export default function ProductRulesPanel() {
|
||||
{({ getFieldValue }) => {
|
||||
const sharingEnabled = getFieldValue('sharingEnabled')
|
||||
const unitMode = getFieldValue('pricingMode') === 'unit'
|
||||
const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift'
|
||||
|
||||
return (
|
||||
<div className="switches-inline-group span-2">
|
||||
@@ -834,8 +900,13 @@ export default function ProductRulesPanel() {
|
||||
<Form.Item label="自动建单" name="autoCreate" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
<Form.Item label="启用拼单" name="sharingEnabled" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
<Form.Item
|
||||
label="启用拼单"
|
||||
name="sharingEnabled"
|
||||
valuePropName="checked"
|
||||
tooltip={friendGiftMode ? '好友赠送模式不支持拼单' : undefined}
|
||||
>
|
||||
<Switch size="small" disabled={friendGiftMode} />
|
||||
</Form.Item>
|
||||
|
||||
{sharingEnabled ? (
|
||||
|
||||
@@ -72,10 +72,14 @@ export function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void
|
||||
rewardAmount: number
|
||||
paymentAmount: number
|
||||
materialComplete?: boolean
|
||||
acceptanceMode?: string
|
||||
}) {
|
||||
setLoading(true)
|
||||
try {
|
||||
await createAdminMockWorkOrder(values)
|
||||
await createAdminMockWorkOrder({
|
||||
...values,
|
||||
acceptanceMode: values.acceptanceMode || 'standard',
|
||||
})
|
||||
message.success('Mock 工单已生成')
|
||||
await onCreated()
|
||||
} catch (error) {
|
||||
@@ -147,6 +151,20 @@ export function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-complete"
|
||||
label="验收模式"
|
||||
name="acceptanceMode"
|
||||
initialValue="standard"
|
||||
>
|
||||
<Select
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'standard', label: '标准验收' },
|
||||
{ value: 'friend_gift', label: '好友赠送' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item className="worker-order-mock-action">
|
||||
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
|
||||
生成
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
assignAdminWorkOrderToWorker,
|
||||
cancelAdminWorkOrder,
|
||||
cancelAdminWorkOrderShare,
|
||||
confirmAdminWorkOrderFriend,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
deleteAdminWorkOrder,
|
||||
fetchAdminWorkCategories,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
pinAdminWorkOrder,
|
||||
publishAdminWorkOrder,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
resetAdminWorkOrderGiftMaterial,
|
||||
resolveAdminProblemWorkOrder,
|
||||
submitAdminWorkOrderMaterial,
|
||||
unpublishAdminWorkOrder,
|
||||
@@ -74,6 +76,8 @@ import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import {
|
||||
DeadlineCountdown,
|
||||
formatAcceptanceMode,
|
||||
formatGiftPhase,
|
||||
formatTimeoutPolicyLabel,
|
||||
getAcceptanceFiles,
|
||||
getAcceptanceImageUrls,
|
||||
@@ -88,6 +92,7 @@ import {
|
||||
formatMoney,
|
||||
formatSharingShareStatus,
|
||||
formatStatus,
|
||||
resolveGiftPhaseColor,
|
||||
resolveSharingShareStatusColor,
|
||||
resolveStatusColor,
|
||||
} from './shared'
|
||||
@@ -117,6 +122,14 @@ const FILTERABLE_WORK_ORDER_STATUSES = [
|
||||
] as const
|
||||
|
||||
const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending'
|
||||
|
||||
const GIFT_PHASE_FILTER_OPTIONS = [
|
||||
'material_required',
|
||||
'material_submitted',
|
||||
'friend_countdown',
|
||||
'gift_ready',
|
||||
] as const
|
||||
|
||||
type MaterialFormValues = {
|
||||
fields?: Record<string, string>
|
||||
rewardAmount?: number
|
||||
@@ -145,6 +158,8 @@ export default function WorkOrdersPanel() {
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
const [workerId, setWorkerId] = useState<number | undefined>()
|
||||
const [workerLevelIds, setWorkerLevelIds] = useState<number[]>([])
|
||||
const [acceptanceMode, setAcceptanceMode] = useState<string | undefined>()
|
||||
const [giftPhase, setGiftPhase] = useState<string | undefined>()
|
||||
const [filterWorkerKeyword, setFilterWorkerKeyword] = useState('')
|
||||
const [assignWorkerKeyword, setAssignWorkerKeyword] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -220,6 +235,8 @@ export default function WorkOrdersPanel() {
|
||||
workerId,
|
||||
workerLevelIds.join(','),
|
||||
targetWorkOrderId,
|
||||
acceptanceMode || '',
|
||||
giftPhase || '',
|
||||
page,
|
||||
pageSize,
|
||||
createdRange ? createdRange[0] : '',
|
||||
@@ -230,6 +247,8 @@ export default function WorkOrdersPanel() {
|
||||
status:
|
||||
selectedWorkOrderStatuses.length > 0 ? selectedWorkOrderStatuses.join(',') : undefined,
|
||||
vipEvidencePending: vipEvidencePending || undefined,
|
||||
acceptanceMode: acceptanceMode || undefined,
|
||||
giftPhase: giftPhase || undefined,
|
||||
workOrderId: targetWorkOrderId,
|
||||
keyword,
|
||||
workerId,
|
||||
@@ -277,12 +296,26 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
useEffect(() => {
|
||||
setSelectedAcceptanceOrderIds([])
|
||||
}, [statuses, keyword, workerId, workerLevelIds, targetWorkOrderId, page, pageSize])
|
||||
}, [
|
||||
statuses,
|
||||
keyword,
|
||||
workerId,
|
||||
workerLevelIds,
|
||||
targetWorkOrderId,
|
||||
acceptanceMode,
|
||||
giftPhase,
|
||||
page,
|
||||
pageSize,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWorkerLevelFilter) setWorkerLevelIds([])
|
||||
}, [showWorkerLevelFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (acceptanceMode !== 'friend_gift') setGiftPhase(undefined)
|
||||
}, [acceptanceMode])
|
||||
|
||||
function openAssignModal(row: WorkOrder) {
|
||||
setAssignOrder(row)
|
||||
setAssignWorkerId(undefined)
|
||||
@@ -593,6 +626,8 @@ export default function WorkOrdersPanel() {
|
||||
setStatuses([])
|
||||
setWorkerId(undefined)
|
||||
setWorkerLevelIds([])
|
||||
setAcceptanceMode(undefined)
|
||||
setGiftPhase(undefined)
|
||||
setCreatedRange(null)
|
||||
setPage(1)
|
||||
}
|
||||
@@ -716,6 +751,47 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
function confirmFriendAdded(row: WorkOrder) {
|
||||
const cooldownHours = Number(row.gift?.cooldownHours || 72)
|
||||
modal.confirm({
|
||||
title: '确认已添加打手为游戏好友?',
|
||||
content: (
|
||||
<div>
|
||||
<Typography.Paragraph>
|
||||
请先核对打手提交的昵称 / ID / 区服与主页截图,确认已在游戏内互为好友后再点击确认。
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph>
|
||||
确认后开始 {cooldownHours}{' '}
|
||||
小时赠送冷却,冷却结束前打手不能提交赠送凭证;任务时限将顺延到冷却结束后重新起算。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
),
|
||||
okText: '已加好友,开始冷却',
|
||||
cancelText: '取消',
|
||||
onOk: () =>
|
||||
runAction(
|
||||
() => confirmAdminWorkOrderFriend(row.workOrderId),
|
||||
`已确认加好友,${cooldownHours} 小时冷却开始`,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function requestResetGiftMaterial(row: WorkOrder) {
|
||||
modal.confirm({
|
||||
title: '重置打手好友赠送资料?',
|
||||
content:
|
||||
'打手已提交的资料、加好友时间与冷却进度将全部清零,订单回到“待打手提交资料”阶段,需打手重新提交后再次确认加好友。',
|
||||
okText: '确认重置',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () =>
|
||||
runAction(
|
||||
() => resetAdminWorkOrderGiftMaterial(row.workOrderId),
|
||||
'已重置,等待打手重新提交资料',
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkOrder> = [
|
||||
{
|
||||
title: '订单',
|
||||
@@ -828,6 +904,12 @@ export default function WorkOrdersPanel() {
|
||||
置顶
|
||||
</Tag>
|
||||
) : null}
|
||||
{row.acceptanceMode === 'friend_gift' ? <Tag color="purple">好友赠送</Tag> : null}
|
||||
{row.acceptanceMode === 'friend_gift' && row.gift ? (
|
||||
<Tag color={resolveGiftPhaseColor(row.gift.phase)}>
|
||||
{formatGiftPhase(row.gift.phase)}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
@@ -1003,6 +1085,20 @@ export default function WorkOrdersPanel() {
|
||||
售后
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canConfirmFriend ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UserAddOutlined />}
|
||||
onClick={() => confirmFriendAdded(row)}
|
||||
>
|
||||
确认加好友
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canResetGiftFlow ? (
|
||||
<Button danger onClick={() => requestResetGiftMaterial(row)}>
|
||||
重置打手资料
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canMarkProblem ? (
|
||||
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
|
||||
问题单
|
||||
@@ -1101,6 +1197,36 @@ export default function WorkOrdersPanel() {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
allowClear
|
||||
value={acceptanceMode}
|
||||
placeholder="全部验收模式"
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'standard', label: '标准验收' },
|
||||
{ value: 'friend_gift', label: '好友赠送' },
|
||||
]}
|
||||
onChange={(nextMode) => {
|
||||
setAcceptanceMode(nextMode)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
{acceptanceMode === 'friend_gift' ? (
|
||||
<Select
|
||||
allowClear
|
||||
value={giftPhase}
|
||||
placeholder="全部赠送阶段"
|
||||
style={{ width: 170 }}
|
||||
options={GIFT_PHASE_FILTER_OPTIONS.map((phase) => ({
|
||||
value: phase,
|
||||
label: <Tag color={resolveGiftPhaseColor(phase)}>{formatGiftPhase(phase)}</Tag>,
|
||||
}))}
|
||||
onChange={(nextPhase) => {
|
||||
setGiftPhase(nextPhase)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<AdminRangePicker
|
||||
value={createdRange ? [dayjs(createdRange[0]), dayjs(createdRange[1])] : null}
|
||||
onChange={(dates) => {
|
||||
@@ -1305,6 +1431,93 @@ export default function WorkOrdersPanel() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{detailOrder.acceptanceMode === 'friend_gift' && detailOrder.gift ? (
|
||||
<Card
|
||||
title="好友赠送进度"
|
||||
size="small"
|
||||
extra={
|
||||
<Space>
|
||||
<Tag color="purple">{formatAcceptanceMode(detailOrder.acceptanceMode)}</Tag>
|
||||
<Tag color={resolveGiftPhaseColor(detailOrder.gift.phase)}>
|
||||
{formatGiftPhase(detailOrder.gift.phase)}
|
||||
</Tag>
|
||||
{detailOrder.capabilities?.canConfirmFriend ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => confirmFriendAdded(detailOrder)}
|
||||
>
|
||||
确认加好友
|
||||
</Button>
|
||||
) : null}
|
||||
{detailOrder.capabilities?.canResetGiftFlow ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => requestResetGiftMaterial(detailOrder)}
|
||||
>
|
||||
重置打手资料
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="worker-order-detail-stack">
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
<Descriptions.Item label="冷却时长">
|
||||
{detailOrder.gift.cooldownHours} 小时
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打手资料提交">
|
||||
{formatAdminDateTime(detailOrder.gift.boosterMaterial?.submittedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已加好友">
|
||||
{formatAdminDateTime(detailOrder.gift.friendAddedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="可赠送时间">
|
||||
{detailOrder.gift.giftAvailableAt
|
||||
? formatAdminDateTime(detailOrder.gift.giftAvailableAt)
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
{(
|
||||
[
|
||||
['gameNickname', '游戏昵称'],
|
||||
['gameId', '游戏ID'],
|
||||
['serverZone', '区服'],
|
||||
['system', '系统'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<Descriptions.Item key={key} label={label}>
|
||||
<span className="worker-order-detail-value">
|
||||
{String(detailOrder.gift?.boosterMaterial?.fields?.[key] || '').trim() ||
|
||||
'-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
<Descriptions.Item label="打手备注" span={2}>
|
||||
<span className="worker-order-detail-value">
|
||||
{String(detailOrder.gift?.boosterMaterial?.note || '').trim() || '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div>
|
||||
<Typography.Text type="secondary">打手主页截图</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{detailOrder.gift.boosterMaterial?.screenshots?.length ? (
|
||||
<ImagePreviewList
|
||||
files={detailOrder.gift.boosterMaterial.screenshots}
|
||||
size={72}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未提交</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="验收信息" size="small">
|
||||
<div className="worker-order-detail-stack">
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
|
||||
@@ -244,6 +244,31 @@ export function formatSharingShareStatus(status: string) {
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
export function formatAcceptanceMode(mode: string) {
|
||||
if (mode === 'friend_gift') return '好友赠送'
|
||||
return '标准验收'
|
||||
}
|
||||
|
||||
export function formatGiftPhase(phase: string) {
|
||||
const labels: Record<string, string> = {
|
||||
material_required: '待打手提交资料',
|
||||
material_submitted: '待确认加好友',
|
||||
friend_countdown: '赠送冷却中',
|
||||
gift_ready: '可赠送',
|
||||
}
|
||||
return labels[phase] || phase
|
||||
}
|
||||
|
||||
export function resolveGiftPhaseColor(phase: string) {
|
||||
const colors: Record<string, string> = {
|
||||
material_required: 'orange',
|
||||
material_submitted: 'geekblue',
|
||||
friend_countdown: 'gold',
|
||||
gift_ready: 'green',
|
||||
}
|
||||
return colors[phase] || 'default'
|
||||
}
|
||||
|
||||
export function resolveSharingShareStatusColor(status: string) {
|
||||
if (status === 'accepted') return 'green'
|
||||
if (status === 'submitted') return 'gold'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MessageOutlined, PictureOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Descriptions, Drawer, Tag, Typography } from 'antd'
|
||||
import { MessageOutlined, PictureOutlined, UserAddOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typography } from 'antd'
|
||||
|
||||
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
@@ -11,19 +11,22 @@ import {
|
||||
canRequestCancel,
|
||||
canSubmitAcceptance,
|
||||
canSubmitFeedback,
|
||||
canSubmitGiftMaterial,
|
||||
formatMoney,
|
||||
formatStatus,
|
||||
getAcceptanceSubmittedAt,
|
||||
getDisplayOrderNo,
|
||||
getGiftPhaseMeta,
|
||||
getMaterialDetailItems,
|
||||
getMaterialScreenshots,
|
||||
getOrderStatusDisplay,
|
||||
getOrderStatusHint,
|
||||
getUniqueAcceptanceFiles,
|
||||
getWorkerAcceptance,
|
||||
getWorkerDraft,
|
||||
GiftCountdownTag,
|
||||
hasSubmittedAcceptance,
|
||||
isFriendGiftOrder,
|
||||
renderDetailFieldValues,
|
||||
resolveStatusColor,
|
||||
getStatusHint,
|
||||
} from './worker-order-utils'
|
||||
|
||||
export function WorkerOrderDetailDrawer({
|
||||
@@ -32,6 +35,7 @@ export function WorkerOrderDetailDrawer({
|
||||
orderTimelineVisible,
|
||||
onClose,
|
||||
onOpenAcceptance,
|
||||
onOpenGiftMaterial,
|
||||
onOpenCancelRequest,
|
||||
onOpenFeedback,
|
||||
}: {
|
||||
@@ -40,6 +44,7 @@ export function WorkerOrderDetailDrawer({
|
||||
orderTimelineVisible: boolean
|
||||
onClose: () => void
|
||||
onOpenAcceptance: (order: WorkOrder) => void
|
||||
onOpenGiftMaterial: (order: WorkOrder) => void
|
||||
onOpenCancelRequest: (order: WorkOrder) => void
|
||||
onOpenFeedback: (order: WorkOrder) => void
|
||||
}) {
|
||||
@@ -55,7 +60,18 @@ export function WorkerOrderDetailDrawer({
|
||||
{order ? (
|
||||
<div className="worker-order-detail-stack">
|
||||
<div className="worker-order-detail-actions">
|
||||
<Tag color={resolveStatusColor(order.status)}>{formatStatus(order.status)}</Tag>
|
||||
<Tag color={getOrderStatusDisplay(order).color}>
|
||||
{getOrderStatusDisplay(order).label}
|
||||
</Tag>
|
||||
{canSubmitGiftMaterial(order) ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UserAddOutlined />}
|
||||
onClick={() => onOpenGiftMaterial(order)}
|
||||
>
|
||||
提交好友赠送资料
|
||||
</Button>
|
||||
) : null}
|
||||
{canEditAcceptanceImages(order) ? (
|
||||
<Button
|
||||
type={canSubmitAcceptance(order) ? 'primary' : 'default'}
|
||||
@@ -82,9 +98,11 @@ export function WorkerOrderDetailDrawer({
|
||||
<Descriptions.Item label="分类">{order.categoryName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">{getDisplayOrderNo(order)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={resolveStatusColor(order.status)}>{formatStatus(order.status)}</Tag>
|
||||
<Tag color={getOrderStatusDisplay(order).color}>
|
||||
{getOrderStatusDisplay(order).label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="阶段说明">{getStatusHint(order.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="阶段说明">{getOrderStatusHint(order)}</Descriptions.Item>
|
||||
<Descriptions.Item label="接单奖励">
|
||||
{formatMoney(order.rewardAmount)}
|
||||
</Descriptions.Item>
|
||||
@@ -118,6 +136,8 @@ export function WorkerOrderDetailDrawer({
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{isFriendGiftOrder(order) && order.gift ? <GiftProgressCard order={order} /> : null}
|
||||
|
||||
<Card title="验收信息" size="small">
|
||||
<AcceptanceSummary order={order} />
|
||||
</Card>
|
||||
@@ -141,6 +161,97 @@ export function WorkerOrderDetailDrawer({
|
||||
)
|
||||
}
|
||||
|
||||
function GiftProgressCard({ order }: { order: WorkOrder }) {
|
||||
const gift = order.gift
|
||||
if (!gift) return null
|
||||
const phaseMeta = getGiftPhaseMeta(gift.phase)
|
||||
const stepIndex =
|
||||
gift.phase === 'material_required'
|
||||
? 0
|
||||
: gift.phase === 'material_submitted'
|
||||
? 1
|
||||
: gift.phase === 'friend_countdown'
|
||||
? 2
|
||||
: 3
|
||||
const boosterFields = gift.boosterMaterial?.fields || {}
|
||||
const boosterFieldLabels: Record<string, string> = {
|
||||
gameNickname: '游戏昵称',
|
||||
gameId: '游戏ID',
|
||||
serverZone: '区服',
|
||||
system: '系统',
|
||||
}
|
||||
const boosterItems = Object.entries(boosterFields).filter(([, value]) =>
|
||||
String(value || '').trim(),
|
||||
)
|
||||
|
||||
return (
|
||||
<Card title="好友赠送进度" size="small">
|
||||
<div className="worker-order-detail-stack">
|
||||
<Space size={8} wrap>
|
||||
<Tag color={phaseMeta.color}>{phaseMeta.label}</Tag>
|
||||
{gift.phase === 'friend_countdown' ? (
|
||||
<GiftCountdownTag giftAvailableAt={gift.giftAvailableAt} />
|
||||
) : null}
|
||||
{gift.phase === 'gift_ready' ? (
|
||||
<Tag color="green">可赠送,完成后请提交赠送凭证</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
<Steps
|
||||
size="small"
|
||||
current={stepIndex}
|
||||
items={[
|
||||
{ title: '提交资料' },
|
||||
{ title: '添加好友' },
|
||||
{ title: '冷却等待' },
|
||||
{ title: '赠送交付' },
|
||||
]}
|
||||
/>
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
<Descriptions.Item label="冷却时长">{gift.cooldownHours} 小时</Descriptions.Item>
|
||||
<Descriptions.Item label="已加好友">
|
||||
{formatDateTime(gift.friendAddedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="可赠送时间" span={2}>
|
||||
{gift.giftAvailableAt ? (
|
||||
<Space size={8} wrap>
|
||||
<span>{formatDateTime(gift.giftAvailableAt)}</span>
|
||||
{gift.phase === 'friend_countdown' ? (
|
||||
<GiftCountdownTag giftAvailableAt={gift.giftAvailableAt} />
|
||||
) : null}
|
||||
</Space>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{boosterItems.length > 0 || (gift.boosterMaterial?.screenshots || []).length > 0 ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary">已提交的本人资料</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{boosterItems.length > 0 ? (
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
{boosterItems.map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={boosterFieldLabels[key] || key}>
|
||||
<span className="worker-order-detail-value">{String(value)}</span>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
) : null}
|
||||
{(gift.boosterMaterial?.screenshots || []).length > 0 ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ImagePreviewList files={gift.boosterMaterial.screenshots} size={64} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">尚未提交本人资料</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AcceptanceSummary({ order }: { order: WorkOrder }) {
|
||||
const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order))
|
||||
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order))
|
||||
|
||||
@@ -25,19 +25,21 @@ import {
|
||||
canRequestCancel,
|
||||
canSubmitAcceptance,
|
||||
canSubmitFeedback,
|
||||
canSubmitGiftMaterial,
|
||||
formatMoney,
|
||||
formatStatus,
|
||||
getCopyOrderInfoItems,
|
||||
getDisplayOrderNo,
|
||||
getMaterialDetailItems,
|
||||
getMaterialScreenshots,
|
||||
getOrderStatusDisplay,
|
||||
getSettlementLabel,
|
||||
getUniqueAcceptanceFiles,
|
||||
getWorkerAcceptance,
|
||||
getWorkerDraft,
|
||||
GiftCountdownTag,
|
||||
hasSubmittedAcceptance,
|
||||
isFriendGiftOrder,
|
||||
isVipEvidencePending,
|
||||
resolveStatusColor,
|
||||
} from './worker-order-utils'
|
||||
|
||||
export function WorkerOrderMobileList({
|
||||
@@ -61,6 +63,7 @@ export function WorkerOrderMobileList({
|
||||
onPageChange,
|
||||
onOpenDetail,
|
||||
onOpenAcceptance,
|
||||
onOpenGiftMaterial,
|
||||
onRemindAcceptance,
|
||||
onOpenCancelRequest,
|
||||
onOpenFeedback,
|
||||
@@ -88,6 +91,7 @@ export function WorkerOrderMobileList({
|
||||
onPageChange: (page: number, pageSize: number) => void
|
||||
onOpenDetail: (order: WorkOrder) => void
|
||||
onOpenAcceptance: (order: WorkOrder) => void
|
||||
onOpenGiftMaterial: (order: WorkOrder) => void
|
||||
onRemindAcceptance: (order: WorkOrder) => void
|
||||
onOpenCancelRequest: (order: WorkOrder) => void
|
||||
onOpenFeedback: (order: WorkOrder) => void
|
||||
@@ -159,6 +163,7 @@ export function WorkerOrderMobileList({
|
||||
order={order}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenAcceptance={onOpenAcceptance}
|
||||
onOpenGiftMaterial={onOpenGiftMaterial}
|
||||
onRemindAcceptance={onRemindAcceptance}
|
||||
onOpenCancelRequest={onOpenCancelRequest}
|
||||
onOpenFeedback={onOpenFeedback}
|
||||
@@ -188,6 +193,7 @@ function WorkerOrderMobileCard({
|
||||
order,
|
||||
onOpenDetail,
|
||||
onOpenAcceptance,
|
||||
onOpenGiftMaterial,
|
||||
onRemindAcceptance,
|
||||
onOpenCancelRequest,
|
||||
onOpenFeedback,
|
||||
@@ -221,6 +227,7 @@ function WorkerOrderMobileCard({
|
||||
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order))
|
||||
const noteText = getWorkerAcceptance(order)?.note
|
||||
const workerNote = String(order.workerNote || '').trim()
|
||||
const statusDisplay = getOrderStatusDisplay(order)
|
||||
|
||||
return (
|
||||
<article className="worker-order-mobile-card">
|
||||
@@ -244,10 +251,15 @@ function WorkerOrderMobileCard({
|
||||
<span className="worker-order-mobile-orderno-text">{getDisplayOrderNo(order)}</span>
|
||||
</div>
|
||||
<div className="worker-order-mobile-header-right">
|
||||
<Tag color={resolveStatusColor(order.status)}>{formatStatus(order.status)}</Tag>
|
||||
<Tag color={statusDisplay.color}>{statusDisplay.label}</Tag>
|
||||
{order.status === 'in_progress' && order.deadlineAt ? (
|
||||
<DeadlineCountdown deadlineAt={order.deadlineAt} />
|
||||
) : null}
|
||||
{isFriendGiftOrder(order) &&
|
||||
order.status === 'in_progress' &&
|
||||
order.gift?.phase === 'friend_countdown' ? (
|
||||
<GiftCountdownTag giftAvailableAt={order.gift.giftAvailableAt} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -371,6 +383,16 @@ function WorkerOrderMobileCard({
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => onOpenDetail(order)}>
|
||||
详情
|
||||
</Button>
|
||||
{canSubmitGiftMaterial(order) ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="worker-order-mobile-action-submit"
|
||||
onClick={() => onOpenGiftMaterial(order)}
|
||||
>
|
||||
提交资料
|
||||
</Button>
|
||||
) : null}
|
||||
{canEditAcceptanceImages(order) ? (
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PictureOutlined } from '@ant-design/icons'
|
||||
import { Button, Form, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||
import { Button, Form, Input, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
@@ -22,6 +22,13 @@ export type AcceptanceFormValues = { note?: string }
|
||||
export type WorkerNoteFormValues = { note?: string }
|
||||
export type CancelRequestFormValues = { reason?: string }
|
||||
export type FeedbackFormValues = { content?: string }
|
||||
export type GiftMaterialFormValues = {
|
||||
gameNickname: string
|
||||
gameId: string
|
||||
serverZone: string
|
||||
system?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
|
||||
|
||||
@@ -58,6 +65,12 @@ export function WorkerOrderModals({
|
||||
onAcceptanceFilesChange,
|
||||
onSubmitAcceptance,
|
||||
onSaveAcceptanceImages,
|
||||
giftMaterialOrder,
|
||||
giftMaterialForm,
|
||||
giftMaterialFiles,
|
||||
onCloseGiftMaterial,
|
||||
onGiftMaterialFilesChange,
|
||||
onSubmitGiftMaterial,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
feedbackOrder: WorkOrder | null
|
||||
@@ -82,6 +95,12 @@ export function WorkerOrderModals({
|
||||
onAcceptanceFilesChange: (files: UploadedFile[]) => void
|
||||
onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise<void>
|
||||
onSaveAcceptanceImages: (values: AcceptanceFormValues) => void | Promise<void>
|
||||
giftMaterialOrder: WorkOrder | null
|
||||
giftMaterialForm: FormInstance<GiftMaterialFormValues>
|
||||
giftMaterialFiles: UploadedFile[]
|
||||
onCloseGiftMaterial: () => void
|
||||
onGiftMaterialFilesChange: (files: UploadedFile[]) => void
|
||||
onSubmitGiftMaterial: (values: GiftMaterialFormValues) => void | Promise<void>
|
||||
}) {
|
||||
const feedbackItems = feedbacks.filter((item) => item.workOrderId === feedbackOrder?.workOrderId)
|
||||
return (
|
||||
@@ -266,6 +285,66 @@ export function WorkerOrderModals({
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={`好友赠送资料 · ${getDisplayOrderNo(giftMaterialOrder)}`}
|
||||
open={Boolean(giftMaterialOrder)}
|
||||
width={isMobile ? '96%' : 620}
|
||||
onCancel={onCloseGiftMaterial}
|
||||
onOk={() => giftMaterialForm.submit()}
|
||||
okText="提交资料"
|
||||
destroyOnHidden
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
请填写本人游戏账号信息并上传主页截图;后台核对后会添加好友,好友冷却结束后才能赠送。
|
||||
</Typography.Paragraph>
|
||||
<Form form={giftMaterialForm} layout="vertical" onFinish={onSubmitGiftMaterial}>
|
||||
<Form.Item
|
||||
label="游戏昵称"
|
||||
name="gameNickname"
|
||||
rules={[{ required: true, whitespace: true, message: '请填写游戏昵称' }]}
|
||||
>
|
||||
<Input placeholder="本人游戏昵称" maxLength={50} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="游戏ID"
|
||||
name="gameId"
|
||||
rules={[{ required: true, whitespace: true, message: '请填写游戏ID' }]}
|
||||
>
|
||||
<Input placeholder="本人游戏ID" maxLength={50} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="区服"
|
||||
name="serverZone"
|
||||
rules={[{ required: true, whitespace: true, message: '请填写区服' }]}
|
||||
>
|
||||
<Input placeholder="例如:QQ区 / 微信区 / 具体区服名称" maxLength={50} />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统" name="system" initialValue="">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="">不区分</Radio.Button>
|
||||
<Radio.Button value="安卓">安卓</Radio.Button>
|
||||
<Radio.Button value="苹果">苹果</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="主页截图"
|
||||
required
|
||||
extra="至少上传一张本人游戏主页截图,供后台核对身份。"
|
||||
>
|
||||
<ImageUpload
|
||||
scene="worker-gift-material"
|
||||
scope="worker"
|
||||
value={giftMaterialFiles}
|
||||
onChange={onGiftMaterialFilesChange}
|
||||
maxCount={5}
|
||||
showPasteHint
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="note">
|
||||
<Input.TextArea rows={2} maxLength={200} showCount placeholder="选填" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,19 +19,21 @@ import {
|
||||
canRequestCancel,
|
||||
canSubmitAcceptance,
|
||||
canSubmitFeedback,
|
||||
canSubmitGiftMaterial,
|
||||
formatMoney,
|
||||
formatStatus,
|
||||
getAcceptanceSubmittedAt,
|
||||
getCopyOrderInfoItems,
|
||||
getDisplayOrderNo,
|
||||
getMaterialDetailItems,
|
||||
getMaterialScreenshots,
|
||||
getOrderStatusDisplay,
|
||||
getUniqueAcceptanceFiles,
|
||||
getWorkerAcceptance,
|
||||
getWorkerDraft,
|
||||
GiftCountdownTag,
|
||||
hasSubmittedAcceptance,
|
||||
isFriendGiftOrder,
|
||||
isVipEvidencePending,
|
||||
resolveStatusColor,
|
||||
} from './worker-order-utils'
|
||||
|
||||
type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
|
||||
@@ -39,6 +41,7 @@ type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'd
|
||||
export function buildWorkerOrderTableColumns({
|
||||
onOpenDetail,
|
||||
onOpenAcceptance,
|
||||
onOpenGiftMaterial,
|
||||
onRemindAcceptance,
|
||||
onOpenCancelRequest,
|
||||
onOpenFeedback,
|
||||
@@ -48,6 +51,7 @@ export function buildWorkerOrderTableColumns({
|
||||
}: {
|
||||
onOpenDetail: (order: WorkOrder) => void
|
||||
onOpenAcceptance: (order: WorkOrder) => void
|
||||
onOpenGiftMaterial: (order: WorkOrder) => void
|
||||
onRemindAcceptance: (order: WorkOrder) => void
|
||||
onOpenCancelRequest: (order: WorkOrder) => void
|
||||
onOpenFeedback: (order: WorkOrder) => void
|
||||
@@ -96,25 +100,33 @@ export function buildWorkerOrderTableColumns({
|
||||
{
|
||||
title: '状态',
|
||||
width: 130,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Space wrap size={4}>
|
||||
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
|
||||
{row.status === 'in_progress' && row.deadlineAt ? (
|
||||
<DeadlineCountdown deadlineAt={row.deadlineAt} />
|
||||
render: (_, row) => {
|
||||
const statusDisplay = getOrderStatusDisplay(row)
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<Space wrap size={4}>
|
||||
<Tag color={statusDisplay.color}>{statusDisplay.label}</Tag>
|
||||
{row.status === 'in_progress' && row.deadlineAt ? (
|
||||
<DeadlineCountdown deadlineAt={row.deadlineAt} />
|
||||
) : null}
|
||||
{isFriendGiftOrder(row) &&
|
||||
row.status === 'in_progress' &&
|
||||
row.gift?.phase === 'friend_countdown' ? (
|
||||
<GiftCountdownTag giftAvailableAt={row.gift.giftAvailableAt} />
|
||||
) : null}
|
||||
</Space>
|
||||
{row.status === 'problem' && String(row.problemNote || '').trim() ? (
|
||||
<Typography.Text
|
||||
type="danger"
|
||||
ellipsis={{ tooltip: row.problemNote }}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{row.problemNote}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{row.status === 'problem' && String(row.problemNote || '').trim() ? (
|
||||
<Typography.Text
|
||||
type="danger"
|
||||
ellipsis={{ tooltip: row.problemNote }}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{row.problemNote}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '奖励 / 押金',
|
||||
@@ -205,6 +217,11 @@ export function buildWorkerOrderTableColumns({
|
||||
onClick={() => onOpenDetail(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{canSubmitGiftMaterial(row) ? (
|
||||
<Button type="primary" onClick={() => onOpenGiftMaterial(row)}>
|
||||
提交资料
|
||||
</Button>
|
||||
) : null}
|
||||
{canEditAcceptanceImages(row) ? (
|
||||
<Button
|
||||
icon={<PictureOutlined />}
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
remindWorkerAcceptance,
|
||||
saveWorkerAcceptanceDraft,
|
||||
saveWorkerOrderNote,
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
submitWorkerAcceptance,
|
||||
submitWorkerCancelRequest,
|
||||
submitWorkerGiftMaterial,
|
||||
submitWorkerOrderFeedback,
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
} from '@/services/worker'
|
||||
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
|
||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
type AcceptanceFormValues,
|
||||
type CancelRequestFormValues,
|
||||
type FeedbackFormValues,
|
||||
type GiftMaterialFormValues,
|
||||
type WorkerNoteFormValues,
|
||||
WorkerOrderModals,
|
||||
} from './WorkerOrderModals'
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
getWorkerAcceptance,
|
||||
getWorkerDraft,
|
||||
hasSubmittedAcceptance,
|
||||
isGiftSubmissionBlocked,
|
||||
patchWorkOrderReadState,
|
||||
toPngBlob,
|
||||
} from './worker-order-utils'
|
||||
@@ -165,7 +168,10 @@ export default function WorkerOrdersPage() {
|
||||
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
|
||||
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
|
||||
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
||||
const [giftMaterialOrder, setGiftMaterialOrder] = useState<WorkOrder | null>(null)
|
||||
const [giftMaterialFiles, setGiftMaterialFiles] = useState<UploadedFile[]>([])
|
||||
const [form] = Form.useForm<AcceptanceFormValues>()
|
||||
const [giftMaterialForm] = Form.useForm<GiftMaterialFormValues>()
|
||||
const [noteForm] = Form.useForm<WorkerNoteFormValues>()
|
||||
const [cancelRequestForm] = Form.useForm<CancelRequestFormValues>()
|
||||
const [feedbackForm] = Form.useForm<FeedbackFormValues>()
|
||||
@@ -292,6 +298,43 @@ export default function WorkerOrdersPage() {
|
||||
setDetailOrder(null)
|
||||
}
|
||||
|
||||
function openGiftMaterialModal(order: WorkOrder) {
|
||||
markOrderAsRead(order)
|
||||
setGiftMaterialOrder(order)
|
||||
setGiftMaterialFiles([])
|
||||
giftMaterialForm.resetFields()
|
||||
setDetailOrder(null)
|
||||
}
|
||||
|
||||
async function submitGiftMaterial(values: GiftMaterialFormValues) {
|
||||
if (!giftMaterialOrder) return
|
||||
if (giftMaterialFiles.length === 0) {
|
||||
message.error('请上传主页截图')
|
||||
return
|
||||
}
|
||||
const fields: Record<string, string> = {
|
||||
gameNickname: String(values.gameNickname || '').trim(),
|
||||
gameId: String(values.gameId || '').trim(),
|
||||
serverZone: String(values.serverZone || '').trim(),
|
||||
}
|
||||
const system = String(values.system || '').trim()
|
||||
if (system) fields.system = system
|
||||
try {
|
||||
await submitWorkerGiftMaterial(giftMaterialOrder.workOrderId, {
|
||||
fields,
|
||||
screenshots: giftMaterialFiles,
|
||||
note: String(values.note || '').trim(),
|
||||
})
|
||||
message.success('资料已提交,等待后台确认加好友')
|
||||
setGiftMaterialOrder(null)
|
||||
setGiftMaterialFiles([])
|
||||
giftMaterialForm.resetFields()
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '提交资料失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openNoteModal(order: WorkOrder) {
|
||||
markOrderAsRead(order)
|
||||
setNoteOrder(order)
|
||||
@@ -386,6 +429,11 @@ export default function WorkerOrdersPage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (isGiftSubmissionBlocked(editingOrder)) {
|
||||
message.error('好友赠送冷却未结束,暂不能提交验收')
|
||||
return
|
||||
}
|
||||
|
||||
const canAutoAccept = canAutoAcceptOrder(editingOrder, workerCanAutoAcceptWithoutEvidence)
|
||||
if (acceptanceFiles.length === 0 && !canAutoAccept) {
|
||||
message.error('请上传验收图片')
|
||||
@@ -437,6 +485,7 @@ export default function WorkerOrdersPage() {
|
||||
const columns = buildWorkerOrderTableColumns({
|
||||
onOpenDetail: openOrderDetail,
|
||||
onOpenAcceptance: openAcceptanceModal,
|
||||
onOpenGiftMaterial: openGiftMaterialModal,
|
||||
onRemindAcceptance: (order) => {
|
||||
void submitAcceptanceReminder(order)
|
||||
},
|
||||
@@ -519,6 +568,7 @@ export default function WorkerOrdersPage() {
|
||||
}}
|
||||
onOpenDetail={openOrderDetail}
|
||||
onOpenAcceptance={openAcceptanceModal}
|
||||
onOpenGiftMaterial={openGiftMaterialModal}
|
||||
onRemindAcceptance={(order) => {
|
||||
void submitAcceptanceReminder(order)
|
||||
}}
|
||||
@@ -574,6 +624,7 @@ export default function WorkerOrdersPage() {
|
||||
orderTimelineVisible={orderTimelineVisible}
|
||||
onClose={() => setDetailOrder(null)}
|
||||
onOpenAcceptance={openAcceptanceModal}
|
||||
onOpenGiftMaterial={openGiftMaterialModal}
|
||||
onOpenCancelRequest={openCancelRequestModal}
|
||||
onOpenFeedback={openFeedbackModal}
|
||||
/>
|
||||
@@ -614,6 +665,16 @@ export default function WorkerOrdersPage() {
|
||||
onAcceptanceFilesChange={setAcceptanceFiles}
|
||||
onSubmitAcceptance={submitAcceptance}
|
||||
onSaveAcceptanceImages={saveAcceptanceImages}
|
||||
giftMaterialOrder={giftMaterialOrder}
|
||||
giftMaterialForm={giftMaterialForm}
|
||||
giftMaterialFiles={giftMaterialFiles}
|
||||
onCloseGiftMaterial={() => {
|
||||
setGiftMaterialOrder(null)
|
||||
setGiftMaterialFiles([])
|
||||
giftMaterialForm.resetFields()
|
||||
}}
|
||||
onGiftMaterialFilesChange={setGiftMaterialFiles}
|
||||
onSubmitGiftMaterial={submitGiftMaterial}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Descriptions, Space, Tag, Typography } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type {
|
||||
CollectField,
|
||||
@@ -32,10 +33,65 @@ export function patchWorkOrderReadState(current: unknown, workOrderId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 好友赠送验收模式(整单,不参与拼单)。 */
|
||||
export function isFriendGiftOrder(order: WorkOrder) {
|
||||
return order.acceptanceMode === 'friend_gift' && !order.myShare
|
||||
}
|
||||
|
||||
export function getGiftPhaseMeta(phase: string): { label: string; color: string } {
|
||||
const metas: Record<string, { label: string; color: string }> = {
|
||||
material_required: { label: '待提交资料', color: 'orange' },
|
||||
material_submitted: { label: '待后台加好友', color: 'processing' },
|
||||
friend_countdown: { label: '赠送冷却中', color: 'gold' },
|
||||
gift_ready: { label: '可赠送', color: 'green' },
|
||||
}
|
||||
return metas[String(phase || '').trim()] || { label: String(phase || '-'), color: 'default' }
|
||||
}
|
||||
|
||||
/** 好友赠送模式:待提交本人资料阶段。 */
|
||||
export function canSubmitGiftMaterial(order: WorkOrder) {
|
||||
return (
|
||||
isFriendGiftOrder(order) &&
|
||||
['in_progress', 'problem'].includes(order.status) &&
|
||||
order.gift?.phase === 'material_required'
|
||||
)
|
||||
}
|
||||
|
||||
/** 好友赠送模式:冷却未结束前不能提交赠送凭证。 */
|
||||
export function isGiftSubmissionBlocked(order: WorkOrder) {
|
||||
if (!isFriendGiftOrder(order)) return false
|
||||
if (!['in_progress', 'problem'].includes(order.status)) return false
|
||||
return order.gift?.phase !== 'gift_ready'
|
||||
}
|
||||
|
||||
/** 好友赠送单进行中按赠送阶段展示状态,其他模式维持原状态文案。 */
|
||||
export function getOrderStatusDisplay(order: WorkOrder): { label: string; color: string } {
|
||||
if (isFriendGiftOrder(order) && order.status === 'in_progress' && order.gift) {
|
||||
return getGiftPhaseMeta(order.gift.phase)
|
||||
}
|
||||
return { label: formatStatus(order.status), color: resolveStatusColor(order.status) }
|
||||
}
|
||||
|
||||
export function getOrderStatusHint(order: WorkOrder) {
|
||||
if (isFriendGiftOrder(order) && order.status === 'in_progress' && order.gift) {
|
||||
const hints: Record<string, string> = {
|
||||
material_required: '请提交本人游戏资料(昵称/ID/区服 + 主页截图),后台核对后添加好友。',
|
||||
material_submitted: '资料已提交,等待后台添加游戏好友并确认。',
|
||||
friend_countdown: '好友已添加,冷却结束后才能赠送。',
|
||||
gift_ready: '冷却已结束,赠送完成后请上传赠送凭证并提交验收。',
|
||||
}
|
||||
return hints[order.gift.phase] || getStatusHint(order.status)
|
||||
}
|
||||
return getStatusHint(order.status)
|
||||
}
|
||||
|
||||
export function canSubmitAcceptance(order: WorkOrder) {
|
||||
if (order.myShare) {
|
||||
return order.myShare.status === 'joined' && order.status !== 'accepted'
|
||||
}
|
||||
if (isFriendGiftOrder(order)) {
|
||||
return ['in_progress', 'problem'].includes(order.status) && order.gift?.phase === 'gift_ready'
|
||||
}
|
||||
return ['in_progress', 'problem'].includes(order.status)
|
||||
}
|
||||
|
||||
@@ -59,6 +115,8 @@ export function canSubmitFeedback(order: WorkOrder) {
|
||||
}
|
||||
|
||||
export function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) {
|
||||
// 好友赠送模式必须由后台人工验收赠送凭证,不适用 VIP 免审核直通。
|
||||
if (isFriendGiftOrder(order)) return false
|
||||
if (!workerCanAutoAcceptWithoutEvidence) return false
|
||||
if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted'
|
||||
return order.sharing?.enabled !== true && ['in_progress', 'problem'].includes(order.status)
|
||||
@@ -265,6 +323,23 @@ export function getAcceptanceSubmittedAt(order: WorkOrder): string | null {
|
||||
return order.submittedAt || acceptanceSubmittedAt || null
|
||||
}
|
||||
|
||||
/** 好友赠送冷却倒计时:按分钟刷新,展示“X天X小时后可赠送”。 */
|
||||
export function GiftCountdownTag({ giftAvailableAt }: { giftAvailableAt: string | null }) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 30_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
const targetMs = Date.parse(String(giftAvailableAt || ''))
|
||||
if (!Number.isFinite(targetMs)) return null
|
||||
const remaining = targetMs - now
|
||||
if (remaining <= 0) return <Tag color="green">可赠送</Tag>
|
||||
const hours = Math.ceil(remaining / 3_600_000)
|
||||
const days = Math.floor(hours / 24)
|
||||
const restHours = hours % 24
|
||||
return <Tag color="gold">{days > 0 ? `${days}天${restHours}小时` : `${hours}小时`}后可赠送</Tag>
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@@ -220,6 +220,8 @@ export function saveAdminWorkProductRule(payload: {
|
||||
sharingTotalAmount?: number
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: string
|
||||
acceptanceMode?: 'standard' | 'friend_gift' | string
|
||||
giftCooldownHours?: number
|
||||
fieldsText?: string
|
||||
sortOrder?: number
|
||||
}) {
|
||||
@@ -525,6 +527,7 @@ export function createAdminMockWorkOrder(payload: {
|
||||
rewardAmount: number
|
||||
paymentAmount: number
|
||||
materialComplete?: boolean
|
||||
acceptanceMode?: 'standard' | 'friend_gift' | string
|
||||
}) {
|
||||
return apiPost<{ order: WorkOrder }>('/api/v1/admin/worker-platform/orders/mock', payload)
|
||||
}
|
||||
@@ -616,6 +619,21 @@ export function markAdminWorkOrderProblem(workOrderId: number, note: string) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 好友赠送模式:核对打手资料并添加好友后确认,开始冷却倒计时 */
|
||||
export function confirmAdminWorkOrderFriend(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/confirm-friend`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 好友赠送模式:重置打手资料,冷却与好友时间清零 */
|
||||
export function resetAdminWorkOrderGiftMaterial(workOrderId: number, note?: string) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/reset-gift-material`,
|
||||
note ? { note } : {},
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveAdminProblemWorkOrder(
|
||||
workOrderId: number,
|
||||
payload: {
|
||||
|
||||
@@ -288,6 +288,17 @@ export function submitWorkerAcceptance(
|
||||
)
|
||||
}
|
||||
|
||||
/** 好友赠送模式:提交打手本人资料(昵称/ID/区服/系统 + 主页截图) */
|
||||
export function submitWorkerGiftMaterial(
|
||||
workOrderId: number,
|
||||
payload: { fields: Record<string, string>; screenshots: UploadedFile[]; note?: string },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/gift-material`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function remindWorkerAcceptance(workOrderId: number) {
|
||||
return apiPost<{ reminderCount: number; nextRemindAt: string }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/remind-acceptance`,
|
||||
|
||||
@@ -338,6 +338,10 @@ export type WorkProductRule = {
|
||||
}
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string
|
||||
/** 验收模式:standard / friend_gift */
|
||||
acceptanceMode?: 'standard' | 'friend_gift' | string
|
||||
/** 好友赠送冷却小时数(默认 72) */
|
||||
giftCooldownHours?: number
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -456,6 +460,31 @@ export type WorkOrderShare = {
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkOrderGiftPhase =
|
||||
| 'material_required'
|
||||
| 'material_submitted'
|
||||
| 'friend_countdown'
|
||||
| 'gift_ready'
|
||||
| string
|
||||
|
||||
/** 好友赠送验收模式的打手本人资料 */
|
||||
export type BoosterMaterial = {
|
||||
fields: Record<string, string>
|
||||
screenshots: UploadedFile[]
|
||||
note: string
|
||||
submittedAt: string
|
||||
}
|
||||
|
||||
/** 好友赠送验收模式进度;phase 含实时计算的 gift_ready */
|
||||
export type WorkOrderGiftProgress = {
|
||||
phase: WorkOrderGiftPhase
|
||||
rawPhase: string
|
||||
cooldownHours: number
|
||||
friendAddedAt: string | null
|
||||
giftAvailableAt: string | null
|
||||
boosterMaterial: BoosterMaterial
|
||||
}
|
||||
|
||||
export type WorkOrder = {
|
||||
workOrderId: number
|
||||
orderId?: number | null
|
||||
@@ -468,6 +497,10 @@ export type WorkOrder = {
|
||||
status: WorkOrderStatus
|
||||
rawStatus?: WorkOrderStatus
|
||||
hallAvailable?: boolean
|
||||
/** 验收模式:standard 标准验收 / friend_gift 好友赠送 */
|
||||
acceptanceMode?: 'standard' | 'friend_gift' | string
|
||||
/** 好友赠送模式进度;仅 acceptanceMode = friend_gift 时返回 */
|
||||
gift?: WorkOrderGiftProgress
|
||||
capabilities?: {
|
||||
canEdit: boolean
|
||||
canConfigureSharing: boolean
|
||||
@@ -483,6 +516,8 @@ export type WorkOrder = {
|
||||
canCancel: boolean
|
||||
canResolveProblem: boolean
|
||||
canDeduct: boolean
|
||||
canConfirmFriend?: boolean
|
||||
canResetGiftFlow?: boolean
|
||||
}
|
||||
rewardAmount: number
|
||||
requiredDepositAmount?: number
|
||||
|
||||
Reference in New Issue
Block a user