新增好友赠送验收模式支持打手资料提交加好友冷却与模板配置

This commit is contained in:
yml2213
2026-08-22 11:19:49 +08:00
parent ba35d27976
commit 6aec4a7b40
36 changed files with 1646 additions and 52 deletions
@@ -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-grab-repo.js'
export * from './work-order-cancel-repo.js' export * from './work-order-cancel-repo.js'
export * from './work-order-issue-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-query-repo.js'
export * from './work-order-deposit-repo.js' export * from './work-order-deposit-repo.js'
export * from './work-order-event-repo.js' export * from './work-order-event-repo.js'
@@ -262,6 +262,8 @@ export type WorkProductRuleRow = {
sharing_auto_from_order: boolean sharing_auto_from_order: boolean
timeout_minutes: number timeout_minutes: number
timeout_policy: string timeout_policy: string
acceptance_mode: string
gift_cooldown_hours: number
requirement_json: string | Record<string, unknown> requirement_json: string | Record<string, unknown>
match_json: string | Record<string, unknown> match_json: string | Record<string, unknown>
sort_order: number sort_order: number
@@ -360,6 +362,15 @@ export type WorkOrderRow = {
deadline_at: string | null deadline_at: string | null
timeout_minutes: number timeout_minutes: number
timeout_policy: string timeout_policy: string
/** 验收模式:standard / friend_giftfriend_gift 建单时从接单模板拷贝。 */
acceptance_mode: string
/** 好友赠送阶段:material_required / material_submitted / friend_countdowngift_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 created_at: string
updated_at: string updated_at: string
/** 拼单聚合字段,由工单查询统一计算。 */ /** 拼单聚合字段,由工单查询统一计算。 */
@@ -411,6 +422,10 @@ export type ListInput = {
excludeFilledSharing?: boolean excludeFilledSharing?: boolean
/** VIP 免审核后仍在补图期限内的订单;与状态筛选取并集 */ /** VIP 免审核后仍在补图期限内的订单;与状态筛选取并集 */
vipEvidencePending?: boolean vipEvidencePending?: boolean
/** 验收模式筛选:standard / friend_gift。 */
acceptanceMode?: string
/** 好友赠送阶段筛选:material_required / material_submitted / friend_countdown / gift_ready。 */
giftPhase?: string
/** 置顶订单优先展示(抢单大厅使用) */ /** 置顶订单优先展示(抢单大厅使用) */
pinnedFirst?: boolean pinnedFirst?: boolean
sort?: 'id_desc' | 'worker_claimed_at_desc' sort?: 'id_desc' | 'worker_claimed_at_desc'
@@ -444,6 +459,8 @@ export type WorkOrderCapabilities = {
canCancel: boolean canCancel: boolean
canResolveProblem: boolean canResolveProblem: boolean
canDeduct: boolean canDeduct: boolean
canConfirmFriend: boolean
canResetGiftFlow: boolean
} }
export type ProductRuleListInput = { export type ProductRuleListInput = {
@@ -510,6 +527,8 @@ export type CreateWorkOrderInput = {
sharingUnitReward?: number sharingUnitReward?: number
timeoutMinutes?: number timeoutMinutes?: number
timeoutPolicy?: string timeoutPolicy?: string
acceptanceMode?: string
giftCooldownHours?: number
materialJson: string materialJson: string
requirementJson: string requirementJson: string
now: string now: string
@@ -3,6 +3,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './share
import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
import { getOutstandingDepositAmountWithClient } from './work-order-deposit-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' import type { WorkOrderRow } from './types.js'
export async function cancelWorkerWorkOrder(input: { export async function cancelWorkerWorkOrder(input: {
@@ -47,6 +48,7 @@ export async function cancelWorkerWorkOrder(input: {
assigned_at = NULL, assigned_at = NULL,
deadline_at = NULL, deadline_at = NULL,
hall_queued_at = $1, hall_queued_at = $1,
${RESET_GIFT_FLOW_SQL},
updated_at = $1 updated_at = $1
WHERE id = $2 WHERE id = $2
`, `,
@@ -137,6 +139,7 @@ export async function unassignWorkOrder(input: {
assigned_worker_id = NULL, assigned_worker_id = NULL,
assigned_at = NULL, assigned_at = NULL,
deadline_at = NULL, deadline_at = NULL,
${RESET_GIFT_FLOW_SQL},
updated_at = $1 updated_at = $1
WHERE id = $2 WHERE id = $2
`, `,
@@ -228,6 +231,7 @@ export async function returnAssignedWorkOrderToHall(input: {
deadline_at = NULL, deadline_at = NULL,
published_at = $1, published_at = $1,
hall_queued_at = $1, hall_queued_at = $1,
${RESET_GIFT_FLOW_SQL},
updated_at = $1 updated_at = $1
WHERE id = $2 WHERE id = $2
`, `,
@@ -424,6 +428,7 @@ export async function reopenCancelledWorkOrder(input: {
problem_note = '', problem_note = '',
published_at = NULL, published_at = NULL,
pinned_at = NULL, pinned_at = NULL,
${RESET_GIFT_FLOW_SQL},
updated_at = $1 updated_at = $1
WHERE id = $2 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, last_assigned_worker_id = $1,
assigned_at = $2, assigned_at = $2,
deadline_at = $3, 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 updated_at = $2
WHERE id = $4 WHERE id = $4
AND status = 'open' AND status = 'open'
@@ -180,6 +184,10 @@ export async function assignWorkOrderToWorker(input: {
last_assigned_worker_id = $1, last_assigned_worker_id = $1,
assigned_at = $2, assigned_at = $2,
deadline_at = $3, 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 updated_at = $2
WHERE id = $4 WHERE id = $4
`, `,
@@ -3,6 +3,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './share
import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
import { getOutstandingDepositAmountWithClient } from './work-order-deposit-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' import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js'
export async function resolveProblemWorkOrder(input: { 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, submitted_at = CASE WHEN $3 THEN NULL ELSE submitted_at END,
problem_note = CASE WHEN $2 THEN '' ELSE problem_note END, problem_note = CASE WHEN $2 THEN '' ELSE problem_note END,
deadline_at = CASE deadline_at = CASE
WHEN $1 = 'in_progress' AND timeout_minutes > 0 WHEN $1 = 'in_progress' AND timeout_minutes > 0 THEN
THEN NOW() + (timeout_minutes * INTERVAL '1 minute') 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 WHEN $1 = 'open' THEN NULL
ELSE deadline_at ELSE deadline_at
END, 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 updated_at = $4
WHERE id = $5 WHERE id = $5
`, `,
@@ -237,6 +261,7 @@ export async function settleOverdueWorkOrder(input: {
problem_note = CASE WHEN $1 = 'open' THEN '' ELSE problem_note END, problem_note = CASE WHEN $1 = 'open' THEN '' ELSE problem_note END,
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at 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, hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,
${RESET_GIFT_FLOW_SQL},
updated_at = $2 updated_at = $2
WHERE id = $3 WHERE id = $3
`, `,
@@ -1,9 +1,17 @@
import { query } from '../../db/client.js' import { query } from '../../db/client.js'
import { toJsonString, toPositiveInteger } from './shared.js' import { toJsonString, toPositiveInteger } from './shared.js'
import { getWorkOrderById } from './work-order-query-repo.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' import type { CreateWorkOrderInput, WorkOrderRow } from './types.js'
export async function createWorkOrder(input: CreateWorkOrderInput): Promise<WorkOrderRow | null> { 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 }>( const result = await query<{ id: number }>(
` `
INSERT INTO work_orders ( 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, product_name, category_id, status, reward_amount, required_deposit_amount,
deposit_threshold_amount, sharing_enabled, sharing_total_quantity, deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
sharing_unit_reward, timeout_minutes, timeout_policy, sharing_unit_reward, timeout_minutes, timeout_policy,
acceptance_mode, gift_phase, gift_cooldown_hours,
material_json, requirement_json, created_at, updated_at material_json, requirement_json, created_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $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 RETURNING id
`, `,
@@ -36,6 +47,9 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
toPositiveInteger(input.sharingUnitReward, 0), toPositiveInteger(input.sharingUnitReward, 0),
toPositiveInteger(input.timeoutMinutes, 0), toPositiveInteger(input.timeoutMinutes, 0),
String(input.timeoutPolicy || 'reopen').trim() || 'reopen', String(input.timeoutPolicy || 'reopen').trim() || 'reopen',
acceptanceMode,
isFriendGift ? WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED : '',
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
input.materialJson, input.materialJson,
input.requirementJson, input.requirementJson,
input.now, input.now,
@@ -1,6 +1,10 @@
import { query } from '../../db/client.js' import { query } from '../../db/client.js'
import type { ListInput, WorkOrderRow, WorkOrderStatistics } from './types.js' import type { ListInput, WorkOrderRow, WorkOrderStatistics } from './types.js'
import type { PoolClient } from 'pg' import type { PoolClient } from 'pg'
import {
normalizeWorkOrderAcceptanceMode,
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
export const WORK_ORDER_SELECT = ` export const WORK_ORDER_SELECT = `
SELECT SELECT
wo.*, wo.*,
@@ -167,6 +171,8 @@ export async function listWorkOrders({
visibleAfterIso = '', visibleAfterIso = '',
excludeFilledSharing = false, excludeFilledSharing = false,
vipEvidencePending = false, vipEvidencePending = false,
acceptanceMode = '',
giftPhase = '',
useOperationalStatus = false, useOperationalStatus = false,
pinnedFirst = false, pinnedFirst = false,
sort = 'id_desc', sort = 'id_desc',
@@ -186,6 +192,8 @@ export async function listWorkOrders({
visibleAfterIso, visibleAfterIso,
excludeFilledSharing, excludeFilledSharing,
vipEvidencePending, vipEvidencePending,
acceptanceMode,
giftPhase,
useOperationalStatus, useOperationalStatus,
createdFrom, createdFrom,
createdTo, createdTo,
@@ -382,7 +390,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
})) }))
} }
/** 查询已超过接单时限、仍待处置的工单。 */ /** 查询已超过接单时限、仍待处置的工单。好友赠送单在“待确认好友”阶段等待后台操作,不计打手超时。 */
export async function listOverdueWorkOrders({ export async function listOverdueWorkOrders({
limit = 50, limit = 50,
workerId = 0, workerId = 0,
@@ -399,6 +407,7 @@ export async function listOverdueWorkOrders({
WHERE wo.status = 'in_progress' WHERE wo.status = 'in_progress'
AND wo.deadline_at IS NOT NULL AND wo.deadline_at IS NOT NULL
AND wo.deadline_at < NOW() AND wo.deadline_at < NOW()
AND NOT (wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = 'material_submitted')
${workerClause} ${workerClause}
ORDER BY wo.deadline_at ASC ORDER BY wo.deadline_at ASC
LIMIT $${params.length}`, LIMIT $${params.length}`,
@@ -522,6 +531,8 @@ export function buildWorkOrderWhere({
visibleAfterIso = '', visibleAfterIso = '',
excludeFilledSharing = false, excludeFilledSharing = false,
vipEvidencePending = false, vipEvidencePending = false,
acceptanceMode = '',
giftPhase = '',
useOperationalStatus = false, useOperationalStatus = false,
createdFrom = '', createdFrom = '',
createdTo = '', createdTo = '',
@@ -685,6 +696,25 @@ export function buildWorkOrderWhere({
params.push(categoryId) params.push(categoryId)
filters.push(`wo.category_id = $${params.length}`) 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) { if (hallCandidateLimit > 0) {
params.push(hallCandidateLimit) params.push(hallCandidateLimit)
filters.push(`wo.id IN (${buildHallCandidateIdsSql(`$${params.length}`)})`) filters.push(`wo.id IN (${buildHallCandidateIdsSql(`$${params.length}`)})`)
@@ -1,5 +1,9 @@
import { query, withTransaction } from '../../db/client.js' import { query, withTransaction } from '../../db/client.js'
import { toPositiveInteger } from './shared.js' import { toPositiveInteger } from './shared.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
} from '../../domain/work-order-acceptance-mode.js'
import type { import type {
ProductRuleListInput, ProductRuleListInput,
WorkOrderRow, WorkOrderRow,
@@ -336,6 +340,8 @@ export async function upsertWorkProductRule(input: {
sharingAutoFromOrder?: boolean sharingAutoFromOrder?: boolean
timeoutMinutes?: number timeoutMinutes?: number
timeoutPolicy?: string timeoutPolicy?: string
acceptanceMode?: string
giftCooldownHours?: number
requirementJson: string requirementJson: string
matchJson?: string matchJson?: string
sortOrder: number sortOrder: number
@@ -350,6 +356,7 @@ export async function upsertWorkProductRule(input: {
required_deposit_amount, deposit_threshold_amount, required_deposit_amount, deposit_threshold_amount,
sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order, sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order,
timeout_minutes, timeout_policy, timeout_minutes, timeout_policy,
acceptance_mode, gift_cooldown_hours,
requirement_json, match_json, requirement_json, match_json,
sort_order, created_at, updated_at sort_order, created_at, updated_at
) VALUES ( ) VALUES (
@@ -359,8 +366,9 @@ export async function upsertWorkProductRule(input: {
$13, $14, $13, $14,
$15, $16, $17, $18, $15, $16, $17, $18,
$19, $20, $19, $20,
$21::jsonb, $22::jsonb, $21, $22,
$23, $24, $25 $23::jsonb, $24::jsonb,
$25, $26, $27
) )
ON CONFLICT (rule_key) DO UPDATE ON CONFLICT (rule_key) DO UPDATE
SET SET
@@ -383,6 +391,8 @@ export async function upsertWorkProductRule(input: {
sharing_auto_from_order = EXCLUDED.sharing_auto_from_order, sharing_auto_from_order = EXCLUDED.sharing_auto_from_order,
timeout_minutes = EXCLUDED.timeout_minutes, timeout_minutes = EXCLUDED.timeout_minutes,
timeout_policy = EXCLUDED.timeout_policy, timeout_policy = EXCLUDED.timeout_policy,
acceptance_mode = EXCLUDED.acceptance_mode,
gift_cooldown_hours = EXCLUDED.gift_cooldown_hours,
requirement_json = EXCLUDED.requirement_json, requirement_json = EXCLUDED.requirement_json,
match_json = EXCLUDED.match_json, match_json = EXCLUDED.match_json,
sort_order = EXCLUDED.sort_order, sort_order = EXCLUDED.sort_order,
@@ -410,6 +420,8 @@ export async function upsertWorkProductRule(input: {
input.sharingAutoFromOrder === true, input.sharingAutoFromOrder === true,
toPositiveInteger(input.timeoutMinutes, 0), toPositiveInteger(input.timeoutMinutes, 0),
String(input.timeoutPolicy || 'reopen').trim(), String(input.timeoutPolicy || 'reopen').trim(),
normalizeWorkOrderAcceptanceMode(input.acceptanceMode),
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
input.requirementJson, input.requirementJson,
input.matchJson || '{}', input.matchJson || '{}',
input.sortOrder, input.sortOrder,
@@ -4,6 +4,7 @@ import {
acceptAdminWorkOrders, acceptAdminWorkOrders,
assignAdminWorkOrderToWorker, assignAdminWorkOrderToWorker,
cancelAdminWorkOrder, cancelAdminWorkOrder,
confirmAdminWorkOrderFriendAdded,
createAdminMockWorkOrder, createAdminMockWorkOrder,
deleteAdminWorkOrder, deleteAdminWorkOrder,
getAdminWorkOrderEvents, getAdminWorkOrderEvents,
@@ -13,6 +14,7 @@ import {
publishAdminWorkOrder, publishAdminWorkOrder,
reopenAdminCancelledWorkOrder, reopenAdminCancelledWorkOrder,
reprocessAdminKuaishouSendCodeWorkOrders, reprocessAdminKuaishouSendCodeWorkOrders,
resetAdminWorkOrderGiftMaterial,
submitAdminWorkOrderMaterial, submitAdminWorkOrderMaterial,
unassignAdminWorkOrder, unassignAdminWorkOrder,
unpublishAdminWorkOrder, 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( router.get(
'/worker-platform/orders/:workOrderId/sharing', '/worker-platform/orders/:workOrderId/sharing',
requireAdminRoles(['admin', 'operator', 'support']), requireAdminRoles(['admin', 'operator', 'support']),
+19
View File
@@ -16,6 +16,7 @@ import {
submitWorkerCancelRequest, submitWorkerCancelRequest,
submitWorkerOrderAcceptance, submitWorkerOrderAcceptance,
submitWorkerOrderFeedback, submitWorkerOrderFeedback,
submitWorkerOrderGiftMaterial,
supplementWorkerAcceptedOrderEvidence, supplementWorkerAcceptedOrderEvidence,
} from '../../services/worker-platform/index.js' } from '../../services/worker-platform/index.js'
import { getRequiredWorkerSession, requireActiveWorker } from './session.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( router.post(
'/orders/:workOrderId/remind-acceptance', '/orders/:workOrderId/remind-acceptance',
requireActiveWorker, requireActiveWorker,
@@ -1,4 +1,8 @@
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' 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 { createWorkOrder, createWorkOrderEvent } from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js' import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js' import { createHttpError } from '../../utils/http.js'
@@ -78,6 +82,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
depositThresholdAmount, depositThresholdAmount,
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
acceptanceMode: normalizeWorkOrderAcceptanceMode(payload.acceptanceMode),
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(payload.giftCooldownHours),
materialJson: JSON.stringify(material), materialJson: JSON.stringify(material),
requirementJson: JSON.stringify({ fields }), requirementJson: JSON.stringify({ fields }),
now, 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 vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false)
const createdFrom = normalizeDateString(query.createdFrom) const createdFrom = normalizeDateString(query.createdFrom)
const createdTo = normalizeDateString(query.createdTo) const createdTo = normalizeDateString(query.createdTo)
const acceptanceMode = String(query.acceptanceMode || '').trim()
const giftPhase = String(query.giftPhase || '').trim()
const filters = { const filters = {
statuses, statuses,
workOrderId, workOrderId,
@@ -45,6 +47,8 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
workerSharingId: workerId, workerSharingId: workerId,
workerLevelIds, workerLevelIds,
vipEvidencePending, vipEvidencePending,
...(acceptanceMode ? { acceptanceMode } : {}),
...(giftPhase ? { giftPhase } : {}),
useOperationalStatus: true, useOperationalStatus: true,
...(createdFrom ? { createdFrom } : {}), ...(createdFrom ? { createdFrom } : {}),
...(createdTo ? { createdTo } : {}), ...(createdTo ? { createdTo } : {}),
@@ -23,6 +23,11 @@ import {
upsertWorkProductRule, upsertWorkProductRule,
} from '../../repositories/worker-platform/work-product-rule-repo.js' } from '../../repositories/worker-platform/work-product-rule-repo.js'
import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.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 type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js' import { createHttpError } from '../../utils/http.js'
import { randomId } from '../../utils/random.js' import { randomId } from '../../utils/random.js'
@@ -257,6 +262,17 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
? 0 ? 0
: normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0) : normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0)
const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false) 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 = const hasManualSharingConfig =
hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) || hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) ||
hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) || hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) ||
@@ -351,6 +367,13 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
sharingAutoFromOrder, sharingAutoFromOrder,
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), 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 }), requirementJson: JSON.stringify({ fields }),
matchJson: JSON.stringify(match), matchJson: JSON.stringify(match),
sortOrder: normalizeInteger(payload.sortOrder, 100), 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-order-realtime-service.js'
export * from './worker-collect-service.js' export * from './worker-collect-service.js'
export * from './worker-acceptance-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-my-order-service.js'
export * from './worker-hall-action-service.js' export * from './worker-hall-action-service.js'
export * from './worker-platform-defaults.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-sharing-service.js'
export * from './admin-work-order-realtime-service.js' export * from './admin-work-order-realtime-service.js'
export * from './admin-work-order-material-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-mock-work-order-service.js'
export * from './admin-work-order-management-service.js' export * from './admin-work-order-management-service.js'
export * from './admin-worker-platform-summary-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 { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
isFriendGiftAcceptanceMode,
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
} from '../../domain/work-order-acceptance-mode.js'
import { import {
createWorkProductMatchLog, createWorkProductMatchLog,
createWorkOrder, createWorkOrder,
@@ -95,10 +100,9 @@ export async function syncWorkerOrdersForSourceOrder(
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0), sharingUnitReward: Number(rule.sharing_unit_reward || 0),
}) })
const sharingEnabled = resolveWorkOrderSharingEnabled( const sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode)
rule.sharing_enabled === true, ? false
sharingTotalQuantity, : resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity)
)
const now = nowIso() const now = nowIso()
const materialComplete = fields.every((field) => !field.required) const materialComplete = fields.every((field) => !field.required)
const workOrder = await createWorkOrder({ const workOrder = await createWorkOrder({
@@ -119,6 +123,8 @@ export async function syncWorkerOrdersForSourceOrder(
sharingUnitReward, sharingUnitReward,
timeoutMinutes: Number(rule.timeout_minutes || 0), timeoutMinutes: Number(rule.timeout_minutes || 0),
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(), timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
materialJson: JSON.stringify({ materialJson: JSON.stringify({
source: { source: {
orderId: Number(order.id), orderId: Number(order.id),
@@ -1,4 +1,12 @@
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' 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 { import {
type WorkCategoryRow, type WorkCategoryRow,
type WorkOrderCapabilities, 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。 */ /** 统一计算后台使用的工单业务状态,避免拼单订单继续直接暴露母工单 open。 */
export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string { export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string {
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
@@ -100,6 +122,9 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
const sharingFilled = const sharingFilled =
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
const status = resolveWorkOrderOperationalStatus(workOrder) 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 = const hallAvailable =
workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.status === WORK_ORDER_STATUS.OPEN &&
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) (!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), canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id),
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM, canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED, 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 { return {
workOrderId: Number(workOrder.id), workOrderId: Number(workOrder.id),
@@ -155,6 +192,19 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
status, status,
rawStatus: workOrder.status, rawStatus: workOrder.status,
hallAvailable, 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: { sharingProgress: {
joinedQuantity: activeShareQuantity, joinedQuantity: activeShareQuantity,
pendingSubmissionCount: pendingShareCount, pendingSubmissionCount: pendingShareCount,
@@ -2,6 +2,10 @@ import {
type WorkProductRuleMappingRow, type WorkProductRuleMappingRow,
type WorkProductRuleRow, type WorkProductRuleRow,
} from '../../repositories/worker-platform/index.js' } 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 { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { safeParseJson } from '../admin/admin-query-utils.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), timeoutMinutes: Number(rule.timeout_minutes || 0),
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(), timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
requirement: { requirement: {
fields: normalizeRequirementFields(requirement.fields), fields: normalizeRequirementFields(requirement.fields),
}, },
@@ -1,4 +1,9 @@
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' 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 { import {
acceptWorkOrderAndSettle, acceptWorkOrderAndSettle,
acceptWorkOrderShareAndSettle, acceptWorkOrderShareAndSettle,
@@ -61,13 +66,20 @@ export async function submitWorkerOrderAcceptance(
if (sharingShare) { if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
} }
const canAutoAcceptWithoutEvidence = canWorkerAutoAcceptWholeOrder({ const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
worker, ? false
hasPersonalSharingShare: false, : canWorkerAutoAcceptWholeOrder({
}) worker,
hasPersonalSharingShare: false,
})
const now = nowIso() const now = nowIso()
// 好友赠送单在“待确认好友”阶段是等后台操作,等待期不计打手超时。
const awaitingFriendConfirm =
isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED
if ( if (
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
!awaitingFriendConfirm &&
workOrder.deadline_at && workOrder.deadline_at &&
new Date(workOrder.deadline_at).getTime() <= Date.now() new Date(workOrder.deadline_at).getTime() <= Date.now()
) { ) {
@@ -95,6 +107,18 @@ export async function submitWorkerOrderAcceptance(
errorCode: 'work_order_submit_status_invalid', 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( const { files, imageUrls, note } = resolveAcceptanceImages(
payload, payload,
@@ -141,7 +165,11 @@ export async function submitWorkerOrderAcceptance(
}), }),
now, now,
}) })
if (worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence) { // 好友赠送模式必须由后台人工验收赠送凭证,内部打手与 VIP 免审核直通均不适用。
if (
!isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
(worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence)
) {
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({ const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id, workOrderId: workOrder.id,
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays, 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, : undefined,
timeoutMinutes: rule.timeoutMinutes || 0, timeoutMinutes: rule.timeoutMinutes || 0,
timeoutPolicy: rule.timeoutPolicy || 'reopen', timeoutPolicy: rule.timeoutPolicy || 'reopen',
acceptanceMode: rule.acceptanceMode || 'standard',
giftCooldownHours: rule.giftCooldownHours || 72,
fieldsText: formatRuleFields(rule.requirement?.fields || []), fieldsText: formatRuleFields(rule.requirement?.fields || []),
sortOrder: rule.sortOrder, sortOrder: rule.sortOrder,
enabled: rule.enabled, enabled: rule.enabled,
@@ -244,6 +246,8 @@ export default function ProductRulesPanel() {
sharingTotalAmount?: number sharingTotalAmount?: number
timeoutMinutes?: number timeoutMinutes?: number
timeoutPolicy?: string timeoutPolicy?: string
acceptanceMode?: string
giftCooldownHours?: number
fieldsText?: string fieldsText?: string
enabled?: boolean enabled?: boolean
autoCreate?: boolean autoCreate?: boolean
@@ -251,6 +255,11 @@ export default function ProductRulesPanel() {
}) { }) {
try { try {
const unitMode = values.pricingMode === 'unit' const unitMode = values.pricingMode === 'unit'
const isFriendGift = values.acceptanceMode === 'friend_gift'
if (isFriendGift && values.sharingEnabled === true) {
message.error('好友赠送验收模式不支持拼单,请先关闭拼单')
return
}
const hasManualSharingOverride = const hasManualSharingOverride =
form.isFieldTouched('sharingTotalQuantity') || form.isFieldTouched('sharingUnitReward') form.isFieldTouched('sharingTotalQuantity') || form.isFieldTouched('sharingUnitReward')
? Number(values.sharingTotalQuantity || 0) > 0 || ? Number(values.sharingTotalQuantity || 0) > 0 ||
@@ -262,6 +271,8 @@ export default function ProductRulesPanel() {
ruleId: editingRule?.ruleId, ruleId: editingRule?.ruleId,
ruleKey: editingRule?.ruleKey || '', ruleKey: editingRule?.ruleKey || '',
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0, unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
acceptanceMode: isFriendGift ? 'friend_gift' : 'standard',
sharingEnabled: isFriendGift ? false : values.sharingEnabled === true,
sharingAutoFromOrder: sharingAutoFromOrder:
unitMode && values.sharingEnabled === true && !hasManualSharingOverride, unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
}) })
@@ -638,6 +649,11 @@ export default function ProductRulesPanel() {
</Tag> </Tag>
) : null} ) : null}
{row.acceptanceMode === 'friend_gift' ? (
<Tag color="orange" style={{ fontSize: 10, margin: 0, padding: '0 3px' }}>
</Tag>
) : null}
</Space> </Space>
</Space> </Space>
), ),
@@ -728,6 +744,8 @@ export default function ProductRulesPanel() {
enabled: true, enabled: true,
autoCreate: true, autoCreate: true,
sharingEnabled: false, sharingEnabled: false,
acceptanceMode: 'standard',
giftCooldownHours: 72,
fieldsText: fieldsText:
'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区', 'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区',
}} }}
@@ -794,6 +812,53 @@ export default function ProductRulesPanel() {
</Form.Item> </Form.Item>
</div> </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: 超时策略 + 来源 + 平台 */} {/* Row 3: 超时策略 + 来源 + 平台 */}
<div className="form-grid-3col"> <div className="form-grid-3col">
<Form.Item label="超时策略" name="timeoutPolicy"> <Form.Item label="超时策略" name="timeoutPolicy">
@@ -825,6 +890,7 @@ export default function ProductRulesPanel() {
{({ getFieldValue }) => { {({ getFieldValue }) => {
const sharingEnabled = getFieldValue('sharingEnabled') const sharingEnabled = getFieldValue('sharingEnabled')
const unitMode = getFieldValue('pricingMode') === 'unit' const unitMode = getFieldValue('pricingMode') === 'unit'
const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift'
return ( return (
<div className="switches-inline-group span-2"> <div className="switches-inline-group span-2">
@@ -834,8 +900,13 @@ export default function ProductRulesPanel() {
<Form.Item label="自动建单" name="autoCreate" valuePropName="checked"> <Form.Item label="自动建单" name="autoCreate" valuePropName="checked">
<Switch size="small" /> <Switch size="small" />
</Form.Item> </Form.Item>
<Form.Item label="启用拼单" name="sharingEnabled" valuePropName="checked"> <Form.Item
<Switch size="small" /> label="启用拼单"
name="sharingEnabled"
valuePropName="checked"
tooltip={friendGiftMode ? '好友赠送模式不支持拼单' : undefined}
>
<Switch size="small" disabled={friendGiftMode} />
</Form.Item> </Form.Item>
{sharingEnabled ? ( {sharingEnabled ? (
@@ -72,10 +72,14 @@ export function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void
rewardAmount: number rewardAmount: number
paymentAmount: number paymentAmount: number
materialComplete?: boolean materialComplete?: boolean
acceptanceMode?: string
}) { }) {
setLoading(true) setLoading(true)
try { try {
await createAdminMockWorkOrder(values) await createAdminMockWorkOrder({
...values,
acceptanceMode: values.acceptanceMode || 'standard',
})
message.success('Mock 工单已生成') message.success('Mock 工单已生成')
await onCreated() await onCreated()
} catch (error) { } catch (error) {
@@ -147,6 +151,20 @@ export function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void
> >
<Switch /> <Switch />
</Form.Item> </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"> <Form.Item className="worker-order-mock-action">
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}> <Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
@@ -49,6 +49,7 @@ import {
assignAdminWorkOrderToWorker, assignAdminWorkOrderToWorker,
cancelAdminWorkOrder, cancelAdminWorkOrder,
cancelAdminWorkOrderShare, cancelAdminWorkOrderShare,
confirmAdminWorkOrderFriend,
deductAdminWorkOrderPendingDeposit, deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder, deleteAdminWorkOrder,
fetchAdminWorkCategories, fetchAdminWorkCategories,
@@ -60,6 +61,7 @@ import {
pinAdminWorkOrder, pinAdminWorkOrder,
publishAdminWorkOrder, publishAdminWorkOrder,
reopenAdminCancelledWorkOrder, reopenAdminCancelledWorkOrder,
resetAdminWorkOrderGiftMaterial,
resolveAdminProblemWorkOrder, resolveAdminProblemWorkOrder,
submitAdminWorkOrderMaterial, submitAdminWorkOrderMaterial,
unpublishAdminWorkOrder, unpublishAdminWorkOrder,
@@ -74,6 +76,8 @@ import { hasAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time' import { formatAdminDateTime } from '@/utils/admin-time'
import { import {
DeadlineCountdown, DeadlineCountdown,
formatAcceptanceMode,
formatGiftPhase,
formatTimeoutPolicyLabel, formatTimeoutPolicyLabel,
getAcceptanceFiles, getAcceptanceFiles,
getAcceptanceImageUrls, getAcceptanceImageUrls,
@@ -88,6 +92,7 @@ import {
formatMoney, formatMoney,
formatSharingShareStatus, formatSharingShareStatus,
formatStatus, formatStatus,
resolveGiftPhaseColor,
resolveSharingShareStatusColor, resolveSharingShareStatusColor,
resolveStatusColor, resolveStatusColor,
} from './shared' } from './shared'
@@ -117,6 +122,14 @@ const FILTERABLE_WORK_ORDER_STATUSES = [
] as const ] as const
const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending' const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending'
const GIFT_PHASE_FILTER_OPTIONS = [
'material_required',
'material_submitted',
'friend_countdown',
'gift_ready',
] as const
type MaterialFormValues = { type MaterialFormValues = {
fields?: Record<string, string> fields?: Record<string, string>
rewardAmount?: number rewardAmount?: number
@@ -145,6 +158,8 @@ export default function WorkOrdersPanel() {
const [keywordInput, setKeywordInput] = useState('') const [keywordInput, setKeywordInput] = useState('')
const [workerId, setWorkerId] = useState<number | undefined>() const [workerId, setWorkerId] = useState<number | undefined>()
const [workerLevelIds, setWorkerLevelIds] = useState<number[]>([]) const [workerLevelIds, setWorkerLevelIds] = useState<number[]>([])
const [acceptanceMode, setAcceptanceMode] = useState<string | undefined>()
const [giftPhase, setGiftPhase] = useState<string | undefined>()
const [filterWorkerKeyword, setFilterWorkerKeyword] = useState('') const [filterWorkerKeyword, setFilterWorkerKeyword] = useState('')
const [assignWorkerKeyword, setAssignWorkerKeyword] = useState('') const [assignWorkerKeyword, setAssignWorkerKeyword] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -220,6 +235,8 @@ export default function WorkOrdersPanel() {
workerId, workerId,
workerLevelIds.join(','), workerLevelIds.join(','),
targetWorkOrderId, targetWorkOrderId,
acceptanceMode || '',
giftPhase || '',
page, page,
pageSize, pageSize,
createdRange ? createdRange[0] : '', createdRange ? createdRange[0] : '',
@@ -230,6 +247,8 @@ export default function WorkOrdersPanel() {
status: status:
selectedWorkOrderStatuses.length > 0 ? selectedWorkOrderStatuses.join(',') : undefined, selectedWorkOrderStatuses.length > 0 ? selectedWorkOrderStatuses.join(',') : undefined,
vipEvidencePending: vipEvidencePending || undefined, vipEvidencePending: vipEvidencePending || undefined,
acceptanceMode: acceptanceMode || undefined,
giftPhase: giftPhase || undefined,
workOrderId: targetWorkOrderId, workOrderId: targetWorkOrderId,
keyword, keyword,
workerId, workerId,
@@ -277,12 +296,26 @@ export default function WorkOrdersPanel() {
}) })
useEffect(() => { useEffect(() => {
setSelectedAcceptanceOrderIds([]) setSelectedAcceptanceOrderIds([])
}, [statuses, keyword, workerId, workerLevelIds, targetWorkOrderId, page, pageSize]) }, [
statuses,
keyword,
workerId,
workerLevelIds,
targetWorkOrderId,
acceptanceMode,
giftPhase,
page,
pageSize,
])
useEffect(() => { useEffect(() => {
if (!showWorkerLevelFilter) setWorkerLevelIds([]) if (!showWorkerLevelFilter) setWorkerLevelIds([])
}, [showWorkerLevelFilter]) }, [showWorkerLevelFilter])
useEffect(() => {
if (acceptanceMode !== 'friend_gift') setGiftPhase(undefined)
}, [acceptanceMode])
function openAssignModal(row: WorkOrder) { function openAssignModal(row: WorkOrder) {
setAssignOrder(row) setAssignOrder(row)
setAssignWorkerId(undefined) setAssignWorkerId(undefined)
@@ -593,6 +626,8 @@ export default function WorkOrdersPanel() {
setStatuses([]) setStatuses([])
setWorkerId(undefined) setWorkerId(undefined)
setWorkerLevelIds([]) setWorkerLevelIds([])
setAcceptanceMode(undefined)
setGiftPhase(undefined)
setCreatedRange(null) setCreatedRange(null)
setPage(1) 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> = [ const columns: TableColumnsType<WorkOrder> = [
{ {
title: '订单', title: '订单',
@@ -828,6 +904,12 @@ export default function WorkOrdersPanel() {
</Tag> </Tag>
) : null} ) : 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> </Space>
) )
}, },
@@ -1003,6 +1085,20 @@ export default function WorkOrdersPanel() {
</Button> </Button>
) : null} ) : 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 ? ( {row.capabilities?.canMarkProblem ? (
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}> <Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
@@ -1101,6 +1197,36 @@ export default function WorkOrdersPanel() {
}} }}
/> />
) : null} ) : 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 <AdminRangePicker
value={createdRange ? [dayjs(createdRange[0]), dayjs(createdRange[1])] : null} value={createdRange ? [dayjs(createdRange[0]), dayjs(createdRange[1])] : null}
onChange={(dates) => { onChange={(dates) => {
@@ -1305,6 +1431,93 @@ export default function WorkOrdersPanel() {
</div> </div>
</Card> </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"> <Card title="验收信息" size="small">
<div className="worker-order-detail-stack"> <div className="worker-order-detail-stack">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small"> <Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
@@ -244,6 +244,31 @@ export function formatSharingShareStatus(status: string) {
return labels[status] || status 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) { export function resolveSharingShareStatusColor(status: string) {
if (status === 'accepted') return 'green' if (status === 'accepted') return 'green'
if (status === 'submitted') return 'gold' if (status === 'submitted') return 'gold'
@@ -1,5 +1,5 @@
import { MessageOutlined, PictureOutlined } from '@ant-design/icons' import { MessageOutlined, PictureOutlined, UserAddOutlined } from '@ant-design/icons'
import { Button, Card, Descriptions, Drawer, Tag, Typography } from 'antd' import { Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typography } from 'antd'
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
import ImagePreviewList from '@/components/files/ImagePreviewList' import ImagePreviewList from '@/components/files/ImagePreviewList'
@@ -11,19 +11,22 @@ import {
canRequestCancel, canRequestCancel,
canSubmitAcceptance, canSubmitAcceptance,
canSubmitFeedback, canSubmitFeedback,
canSubmitGiftMaterial,
formatMoney, formatMoney,
formatStatus,
getAcceptanceSubmittedAt, getAcceptanceSubmittedAt,
getDisplayOrderNo, getDisplayOrderNo,
getGiftPhaseMeta,
getMaterialDetailItems, getMaterialDetailItems,
getMaterialScreenshots, getMaterialScreenshots,
getOrderStatusDisplay,
getOrderStatusHint,
getUniqueAcceptanceFiles, getUniqueAcceptanceFiles,
getWorkerAcceptance, getWorkerAcceptance,
getWorkerDraft, getWorkerDraft,
GiftCountdownTag,
hasSubmittedAcceptance, hasSubmittedAcceptance,
isFriendGiftOrder,
renderDetailFieldValues, renderDetailFieldValues,
resolveStatusColor,
getStatusHint,
} from './worker-order-utils' } from './worker-order-utils'
export function WorkerOrderDetailDrawer({ export function WorkerOrderDetailDrawer({
@@ -32,6 +35,7 @@ export function WorkerOrderDetailDrawer({
orderTimelineVisible, orderTimelineVisible,
onClose, onClose,
onOpenAcceptance, onOpenAcceptance,
onOpenGiftMaterial,
onOpenCancelRequest, onOpenCancelRequest,
onOpenFeedback, onOpenFeedback,
}: { }: {
@@ -40,6 +44,7 @@ export function WorkerOrderDetailDrawer({
orderTimelineVisible: boolean orderTimelineVisible: boolean
onClose: () => void onClose: () => void
onOpenAcceptance: (order: WorkOrder) => void onOpenAcceptance: (order: WorkOrder) => void
onOpenGiftMaterial: (order: WorkOrder) => void
onOpenCancelRequest: (order: WorkOrder) => void onOpenCancelRequest: (order: WorkOrder) => void
onOpenFeedback: (order: WorkOrder) => void onOpenFeedback: (order: WorkOrder) => void
}) { }) {
@@ -55,7 +60,18 @@ export function WorkerOrderDetailDrawer({
{order ? ( {order ? (
<div className="worker-order-detail-stack"> <div className="worker-order-detail-stack">
<div className="worker-order-detail-actions"> <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) ? ( {canEditAcceptanceImages(order) ? (
<Button <Button
type={canSubmitAcceptance(order) ? 'primary' : 'default'} type={canSubmitAcceptance(order) ? 'primary' : 'default'}
@@ -82,9 +98,11 @@ export function WorkerOrderDetailDrawer({
<Descriptions.Item label="分类">{order.categoryName || '-'}</Descriptions.Item> <Descriptions.Item label="分类">{order.categoryName || '-'}</Descriptions.Item>
<Descriptions.Item label="平台订单号">{getDisplayOrderNo(order)}</Descriptions.Item> <Descriptions.Item label="平台订单号">{getDisplayOrderNo(order)}</Descriptions.Item>
<Descriptions.Item label="状态"> <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>
<Descriptions.Item label="阶段说明">{getStatusHint(order.status)}</Descriptions.Item> <Descriptions.Item label="阶段说明">{getOrderStatusHint(order)}</Descriptions.Item>
<Descriptions.Item label="接单奖励"> <Descriptions.Item label="接单奖励">
{formatMoney(order.rewardAmount)} {formatMoney(order.rewardAmount)}
</Descriptions.Item> </Descriptions.Item>
@@ -118,6 +136,8 @@ export function WorkerOrderDetailDrawer({
) : null} ) : null}
</Card> </Card>
{isFriendGiftOrder(order) && order.gift ? <GiftProgressCard order={order} /> : null}
<Card title="验收信息" size="small"> <Card title="验收信息" size="small">
<AcceptanceSummary order={order} /> <AcceptanceSummary order={order} />
</Card> </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 }) { function AcceptanceSummary({ order }: { order: WorkOrder }) {
const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order)) const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order))
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order)) const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order))
@@ -25,19 +25,21 @@ import {
canRequestCancel, canRequestCancel,
canSubmitAcceptance, canSubmitAcceptance,
canSubmitFeedback, canSubmitFeedback,
canSubmitGiftMaterial,
formatMoney, formatMoney,
formatStatus,
getCopyOrderInfoItems, getCopyOrderInfoItems,
getDisplayOrderNo, getDisplayOrderNo,
getMaterialDetailItems, getMaterialDetailItems,
getMaterialScreenshots, getMaterialScreenshots,
getOrderStatusDisplay,
getSettlementLabel, getSettlementLabel,
getUniqueAcceptanceFiles, getUniqueAcceptanceFiles,
getWorkerAcceptance, getWorkerAcceptance,
getWorkerDraft, getWorkerDraft,
GiftCountdownTag,
hasSubmittedAcceptance, hasSubmittedAcceptance,
isFriendGiftOrder,
isVipEvidencePending, isVipEvidencePending,
resolveStatusColor,
} from './worker-order-utils' } from './worker-order-utils'
export function WorkerOrderMobileList({ export function WorkerOrderMobileList({
@@ -61,6 +63,7 @@ export function WorkerOrderMobileList({
onPageChange, onPageChange,
onOpenDetail, onOpenDetail,
onOpenAcceptance, onOpenAcceptance,
onOpenGiftMaterial,
onRemindAcceptance, onRemindAcceptance,
onOpenCancelRequest, onOpenCancelRequest,
onOpenFeedback, onOpenFeedback,
@@ -88,6 +91,7 @@ export function WorkerOrderMobileList({
onPageChange: (page: number, pageSize: number) => void onPageChange: (page: number, pageSize: number) => void
onOpenDetail: (order: WorkOrder) => void onOpenDetail: (order: WorkOrder) => void
onOpenAcceptance: (order: WorkOrder) => void onOpenAcceptance: (order: WorkOrder) => void
onOpenGiftMaterial: (order: WorkOrder) => void
onRemindAcceptance: (order: WorkOrder) => void onRemindAcceptance: (order: WorkOrder) => void
onOpenCancelRequest: (order: WorkOrder) => void onOpenCancelRequest: (order: WorkOrder) => void
onOpenFeedback: (order: WorkOrder) => void onOpenFeedback: (order: WorkOrder) => void
@@ -159,6 +163,7 @@ export function WorkerOrderMobileList({
order={order} order={order}
onOpenDetail={onOpenDetail} onOpenDetail={onOpenDetail}
onOpenAcceptance={onOpenAcceptance} onOpenAcceptance={onOpenAcceptance}
onOpenGiftMaterial={onOpenGiftMaterial}
onRemindAcceptance={onRemindAcceptance} onRemindAcceptance={onRemindAcceptance}
onOpenCancelRequest={onOpenCancelRequest} onOpenCancelRequest={onOpenCancelRequest}
onOpenFeedback={onOpenFeedback} onOpenFeedback={onOpenFeedback}
@@ -188,6 +193,7 @@ function WorkerOrderMobileCard({
order, order,
onOpenDetail, onOpenDetail,
onOpenAcceptance, onOpenAcceptance,
onOpenGiftMaterial,
onRemindAcceptance, onRemindAcceptance,
onOpenCancelRequest, onOpenCancelRequest,
onOpenFeedback, onOpenFeedback,
@@ -221,6 +227,7 @@ function WorkerOrderMobileCard({
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order)) const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order))
const noteText = getWorkerAcceptance(order)?.note const noteText = getWorkerAcceptance(order)?.note
const workerNote = String(order.workerNote || '').trim() const workerNote = String(order.workerNote || '').trim()
const statusDisplay = getOrderStatusDisplay(order)
return ( return (
<article className="worker-order-mobile-card"> <article className="worker-order-mobile-card">
@@ -244,10 +251,15 @@ function WorkerOrderMobileCard({
<span className="worker-order-mobile-orderno-text">{getDisplayOrderNo(order)}</span> <span className="worker-order-mobile-orderno-text">{getDisplayOrderNo(order)}</span>
</div> </div>
<div className="worker-order-mobile-header-right"> <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 ? ( {order.status === 'in_progress' && order.deadlineAt ? (
<DeadlineCountdown deadlineAt={order.deadlineAt} /> <DeadlineCountdown deadlineAt={order.deadlineAt} />
) : null} ) : null}
{isFriendGiftOrder(order) &&
order.status === 'in_progress' &&
order.gift?.phase === 'friend_countdown' ? (
<GiftCountdownTag giftAvailableAt={order.gift.giftAvailableAt} />
) : null}
</div> </div>
</div> </div>
@@ -371,6 +383,16 @@ function WorkerOrderMobileCard({
<Button size="small" icon={<EyeOutlined />} onClick={() => onOpenDetail(order)}> <Button size="small" icon={<EyeOutlined />} onClick={() => onOpenDetail(order)}>
</Button> </Button>
{canSubmitGiftMaterial(order) ? (
<Button
size="small"
type="primary"
className="worker-order-mobile-action-submit"
onClick={() => onOpenGiftMaterial(order)}
>
</Button>
) : null}
{canEditAcceptanceImages(order) ? ( {canEditAcceptanceImages(order) ? (
<Button <Button
size="small" size="small"
@@ -1,5 +1,5 @@
import { PictureOutlined } from '@ant-design/icons' 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 type { FormInstance } from 'antd'
import ImageUpload from '@/components/files/ImageUpload' import ImageUpload from '@/components/files/ImageUpload'
@@ -22,6 +22,13 @@ export type AcceptanceFormValues = { note?: string }
export type WorkerNoteFormValues = { note?: string } export type WorkerNoteFormValues = { note?: string }
export type CancelRequestFormValues = { reason?: string } export type CancelRequestFormValues = { reason?: string }
export type FeedbackFormValues = { content?: 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' type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
@@ -58,6 +65,12 @@ export function WorkerOrderModals({
onAcceptanceFilesChange, onAcceptanceFilesChange,
onSubmitAcceptance, onSubmitAcceptance,
onSaveAcceptanceImages, onSaveAcceptanceImages,
giftMaterialOrder,
giftMaterialForm,
giftMaterialFiles,
onCloseGiftMaterial,
onGiftMaterialFilesChange,
onSubmitGiftMaterial,
}: { }: {
isMobile: boolean isMobile: boolean
feedbackOrder: WorkOrder | null feedbackOrder: WorkOrder | null
@@ -82,6 +95,12 @@ export function WorkerOrderModals({
onAcceptanceFilesChange: (files: UploadedFile[]) => void onAcceptanceFilesChange: (files: UploadedFile[]) => void
onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise<void> onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise<void>
onSaveAcceptanceImages: (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) const feedbackItems = feedbacks.filter((item) => item.workOrderId === feedbackOrder?.workOrderId)
return ( return (
@@ -266,6 +285,66 @@ export function WorkerOrderModals({
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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, canRequestCancel,
canSubmitAcceptance, canSubmitAcceptance,
canSubmitFeedback, canSubmitFeedback,
canSubmitGiftMaterial,
formatMoney, formatMoney,
formatStatus,
getAcceptanceSubmittedAt, getAcceptanceSubmittedAt,
getCopyOrderInfoItems, getCopyOrderInfoItems,
getDisplayOrderNo, getDisplayOrderNo,
getMaterialDetailItems, getMaterialDetailItems,
getMaterialScreenshots, getMaterialScreenshots,
getOrderStatusDisplay,
getUniqueAcceptanceFiles, getUniqueAcceptanceFiles,
getWorkerAcceptance, getWorkerAcceptance,
getWorkerDraft, getWorkerDraft,
GiftCountdownTag,
hasSubmittedAcceptance, hasSubmittedAcceptance,
isFriendGiftOrder,
isVipEvidencePending, isVipEvidencePending,
resolveStatusColor,
} from './worker-order-utils' } from './worker-order-utils'
type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default' type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
@@ -39,6 +41,7 @@ type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'd
export function buildWorkerOrderTableColumns({ export function buildWorkerOrderTableColumns({
onOpenDetail, onOpenDetail,
onOpenAcceptance, onOpenAcceptance,
onOpenGiftMaterial,
onRemindAcceptance, onRemindAcceptance,
onOpenCancelRequest, onOpenCancelRequest,
onOpenFeedback, onOpenFeedback,
@@ -48,6 +51,7 @@ export function buildWorkerOrderTableColumns({
}: { }: {
onOpenDetail: (order: WorkOrder) => void onOpenDetail: (order: WorkOrder) => void
onOpenAcceptance: (order: WorkOrder) => void onOpenAcceptance: (order: WorkOrder) => void
onOpenGiftMaterial: (order: WorkOrder) => void
onRemindAcceptance: (order: WorkOrder) => void onRemindAcceptance: (order: WorkOrder) => void
onOpenCancelRequest: (order: WorkOrder) => void onOpenCancelRequest: (order: WorkOrder) => void
onOpenFeedback: (order: WorkOrder) => void onOpenFeedback: (order: WorkOrder) => void
@@ -96,25 +100,33 @@ export function buildWorkerOrderTableColumns({
{ {
title: '状态', title: '状态',
width: 130, width: 130,
render: (_, row) => ( render: (_, row) => {
<div className="cell-stack"> const statusDisplay = getOrderStatusDisplay(row)
<Space wrap size={4}> return (
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag> <div className="cell-stack">
{row.status === 'in_progress' && row.deadlineAt ? ( <Space wrap size={4}>
<DeadlineCountdown deadlineAt={row.deadlineAt} /> <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} ) : null}
</Space> </div>
{row.status === 'problem' && String(row.problemNote || '').trim() ? ( )
<Typography.Text },
type="danger"
ellipsis={{ tooltip: row.problemNote }}
style={{ fontSize: 12 }}
>
{row.problemNote}
</Typography.Text>
) : null}
</div>
),
}, },
{ {
title: '奖励 / 押金', title: '奖励 / 押金',
@@ -205,6 +217,11 @@ export function buildWorkerOrderTableColumns({
onClick={() => onOpenDetail(row)} onClick={() => onOpenDetail(row)}
/> />
</Tooltip> </Tooltip>
{canSubmitGiftMaterial(row) ? (
<Button type="primary" onClick={() => onOpenGiftMaterial(row)}>
</Button>
) : null}
{canEditAcceptanceImages(row) ? ( {canEditAcceptanceImages(row) ? (
<Button <Button
icon={<PictureOutlined />} icon={<PictureOutlined />}
@@ -14,10 +14,11 @@ import {
remindWorkerAcceptance, remindWorkerAcceptance,
saveWorkerAcceptanceDraft, saveWorkerAcceptanceDraft,
saveWorkerOrderNote, saveWorkerOrderNote,
supplementWorkerAcceptedOrderEvidence,
submitWorkerAcceptance, submitWorkerAcceptance,
submitWorkerCancelRequest, submitWorkerCancelRequest,
submitWorkerGiftMaterial,
submitWorkerOrderFeedback, submitWorkerOrderFeedback,
supplementWorkerAcceptedOrderEvidence,
} from '@/services/worker' } from '@/services/worker'
import type { UploadedFile, WorkOrder } from '@/types/worker-platform' import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
import { useIsMobile } from '@/utils/use-is-mobile' import { useIsMobile } from '@/utils/use-is-mobile'
@@ -29,6 +30,7 @@ import {
type AcceptanceFormValues, type AcceptanceFormValues,
type CancelRequestFormValues, type CancelRequestFormValues,
type FeedbackFormValues, type FeedbackFormValues,
type GiftMaterialFormValues,
type WorkerNoteFormValues, type WorkerNoteFormValues,
WorkerOrderModals, WorkerOrderModals,
} from './WorkerOrderModals' } from './WorkerOrderModals'
@@ -42,6 +44,7 @@ import {
getWorkerAcceptance, getWorkerAcceptance,
getWorkerDraft, getWorkerDraft,
hasSubmittedAcceptance, hasSubmittedAcceptance,
isGiftSubmissionBlocked,
patchWorkOrderReadState, patchWorkOrderReadState,
toPngBlob, toPngBlob,
} from './worker-order-utils' } from './worker-order-utils'
@@ -165,7 +168,10 @@ export default function WorkerOrdersPage() {
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null) const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null) const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([]) const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
const [giftMaterialOrder, setGiftMaterialOrder] = useState<WorkOrder | null>(null)
const [giftMaterialFiles, setGiftMaterialFiles] = useState<UploadedFile[]>([])
const [form] = Form.useForm<AcceptanceFormValues>() const [form] = Form.useForm<AcceptanceFormValues>()
const [giftMaterialForm] = Form.useForm<GiftMaterialFormValues>()
const [noteForm] = Form.useForm<WorkerNoteFormValues>() const [noteForm] = Form.useForm<WorkerNoteFormValues>()
const [cancelRequestForm] = Form.useForm<CancelRequestFormValues>() const [cancelRequestForm] = Form.useForm<CancelRequestFormValues>()
const [feedbackForm] = Form.useForm<FeedbackFormValues>() const [feedbackForm] = Form.useForm<FeedbackFormValues>()
@@ -292,6 +298,43 @@ export default function WorkerOrdersPage() {
setDetailOrder(null) 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) { function openNoteModal(order: WorkOrder) {
markOrderAsRead(order) markOrderAsRead(order)
setNoteOrder(order) setNoteOrder(order)
@@ -386,6 +429,11 @@ export default function WorkerOrdersPage() {
return return
} }
if (isGiftSubmissionBlocked(editingOrder)) {
message.error('好友赠送冷却未结束,暂不能提交验收')
return
}
const canAutoAccept = canAutoAcceptOrder(editingOrder, workerCanAutoAcceptWithoutEvidence) const canAutoAccept = canAutoAcceptOrder(editingOrder, workerCanAutoAcceptWithoutEvidence)
if (acceptanceFiles.length === 0 && !canAutoAccept) { if (acceptanceFiles.length === 0 && !canAutoAccept) {
message.error('请上传验收图片') message.error('请上传验收图片')
@@ -437,6 +485,7 @@ export default function WorkerOrdersPage() {
const columns = buildWorkerOrderTableColumns({ const columns = buildWorkerOrderTableColumns({
onOpenDetail: openOrderDetail, onOpenDetail: openOrderDetail,
onOpenAcceptance: openAcceptanceModal, onOpenAcceptance: openAcceptanceModal,
onOpenGiftMaterial: openGiftMaterialModal,
onRemindAcceptance: (order) => { onRemindAcceptance: (order) => {
void submitAcceptanceReminder(order) void submitAcceptanceReminder(order)
}, },
@@ -519,6 +568,7 @@ export default function WorkerOrdersPage() {
}} }}
onOpenDetail={openOrderDetail} onOpenDetail={openOrderDetail}
onOpenAcceptance={openAcceptanceModal} onOpenAcceptance={openAcceptanceModal}
onOpenGiftMaterial={openGiftMaterialModal}
onRemindAcceptance={(order) => { onRemindAcceptance={(order) => {
void submitAcceptanceReminder(order) void submitAcceptanceReminder(order)
}} }}
@@ -574,6 +624,7 @@ export default function WorkerOrdersPage() {
orderTimelineVisible={orderTimelineVisible} orderTimelineVisible={orderTimelineVisible}
onClose={() => setDetailOrder(null)} onClose={() => setDetailOrder(null)}
onOpenAcceptance={openAcceptanceModal} onOpenAcceptance={openAcceptanceModal}
onOpenGiftMaterial={openGiftMaterialModal}
onOpenCancelRequest={openCancelRequestModal} onOpenCancelRequest={openCancelRequestModal}
onOpenFeedback={openFeedbackModal} onOpenFeedback={openFeedbackModal}
/> />
@@ -614,6 +665,16 @@ export default function WorkerOrdersPage() {
onAcceptanceFilesChange={setAcceptanceFiles} onAcceptanceFilesChange={setAcceptanceFiles}
onSubmitAcceptance={submitAcceptance} onSubmitAcceptance={submitAcceptance}
onSaveAcceptanceImages={saveAcceptanceImages} 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 { Descriptions, Space, Tag, Typography } from 'antd'
import { useEffect, useState } from 'react'
import type { import type {
CollectField, 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) { export function canSubmitAcceptance(order: WorkOrder) {
if (order.myShare) { if (order.myShare) {
return order.myShare.status === 'joined' && order.status !== 'accepted' 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) return ['in_progress', 'problem'].includes(order.status)
} }
@@ -59,6 +115,8 @@ export function canSubmitFeedback(order: WorkOrder) {
} }
export function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) { export function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) {
// 好友赠送模式必须由后台人工验收赠送凭证,不适用 VIP 免审核直通。
if (isFriendGiftOrder(order)) return false
if (!workerCanAutoAcceptWithoutEvidence) return false if (!workerCanAutoAcceptWithoutEvidence) return false
if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted' if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted'
return order.sharing?.enabled !== true && ['in_progress', 'problem'].includes(order.status) 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 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> { export function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)
@@ -220,6 +220,8 @@ export function saveAdminWorkProductRule(payload: {
sharingTotalAmount?: number sharingTotalAmount?: number
timeoutMinutes?: number timeoutMinutes?: number
timeoutPolicy?: string timeoutPolicy?: string
acceptanceMode?: 'standard' | 'friend_gift' | string
giftCooldownHours?: number
fieldsText?: string fieldsText?: string
sortOrder?: number sortOrder?: number
}) { }) {
@@ -525,6 +527,7 @@ export function createAdminMockWorkOrder(payload: {
rewardAmount: number rewardAmount: number
paymentAmount: number paymentAmount: number
materialComplete?: boolean materialComplete?: boolean
acceptanceMode?: 'standard' | 'friend_gift' | string
}) { }) {
return apiPost<{ order: WorkOrder }>('/api/v1/admin/worker-platform/orders/mock', payload) 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( export function resolveAdminProblemWorkOrder(
workOrderId: number, workOrderId: number,
payload: { payload: {
+11
View File
@@ -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) { export function remindWorkerAcceptance(workOrderId: number) {
return apiPost<{ reminderCount: number; nextRemindAt: string }>( return apiPost<{ reminderCount: number; nextRemindAt: string }>(
`/api/v1/worker/orders/${workOrderId}/remind-acceptance`, `/api/v1/worker/orders/${workOrderId}/remind-acceptance`,
@@ -338,6 +338,10 @@ export type WorkProductRule = {
} }
timeoutMinutes?: number timeoutMinutes?: number
timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string
/** 验收模式:standard / friend_gift */
acceptanceMode?: 'standard' | 'friend_gift' | string
/** 好友赠送冷却小时数(默认 72) */
giftCooldownHours?: number
sortOrder: number sortOrder: number
createdAt: string createdAt: string
updatedAt: 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 = { export type WorkOrder = {
workOrderId: number workOrderId: number
orderId?: number | null orderId?: number | null
@@ -468,6 +497,10 @@ export type WorkOrder = {
status: WorkOrderStatus status: WorkOrderStatus
rawStatus?: WorkOrderStatus rawStatus?: WorkOrderStatus
hallAvailable?: boolean hallAvailable?: boolean
/** 验收模式:standard 标准验收 / friend_gift 好友赠送 */
acceptanceMode?: 'standard' | 'friend_gift' | string
/** 好友赠送模式进度;仅 acceptanceMode = friend_gift 时返回 */
gift?: WorkOrderGiftProgress
capabilities?: { capabilities?: {
canEdit: boolean canEdit: boolean
canConfigureSharing: boolean canConfigureSharing: boolean
@@ -483,6 +516,8 @@ export type WorkOrder = {
canCancel: boolean canCancel: boolean
canResolveProblem: boolean canResolveProblem: boolean
canDeduct: boolean canDeduct: boolean
canConfirmFriend?: boolean
canResetGiftFlow?: boolean
} }
rewardAmount: number rewardAmount: number
requiredDepositAmount?: number requiredDepositAmount?: number