资料字段支持下拉选项与买家截图,大厅按 VIP 等级延迟可见

资料字段:
- CollectField 支持 type(text/select) 与 options,规则字段语法 key:名称#选项1,选项2
- 默认字段改为游戏编号/游戏昵称/系统(安卓,苹果)/区服(QQ区,微信区),旧默认模板字段自动替换为新版
- 采集页与管理端补资料支持单选下拉与买家主页截图上传(新增公开上传接口)
- 管理端工单详情展示买家截图

VIP 延迟可见:
- 等级权限新增 visibleDelaySeconds(默认 VIP1=20s~VIP5=0s,可后台配置)
- 大厅按等级延迟过滤新发布工单,页面提示延迟规则
This commit is contained in:
yml2213
2026-08-01 18:14:37 +08:00
parent 70e64155bc
commit 2412df8b54
17 changed files with 252 additions and 31 deletions
@@ -9,7 +9,7 @@ type ImageUploadProps = {
value?: UploadedFile[]
onChange?: (files: UploadedFile[]) => void
scene: string
scope: 'admin' | 'worker'
scope: 'admin' | 'worker' | 'collect'
maxCount?: number
}
@@ -107,6 +107,7 @@ export default function LevelsPanel() {
depositFreeAmount: level.depositFreeAmount,
maxActiveOrders: level.maxActiveOrders,
upgradeThreshold: level.upgradeThreshold,
visibleDelaySeconds: level.visibleDelaySeconds,
})
}
@@ -121,6 +122,7 @@ export default function LevelsPanel() {
depositFreeAmount?: number
maxActiveOrders?: number
upgradeThreshold?: number
visibleDelaySeconds?: number
}) {
try {
await saveAdminWorkerLevel(values)
@@ -140,6 +142,7 @@ export default function LevelsPanel() {
depositFreeAmount: level.depositFreeAmount,
maxActiveOrders: level.maxActiveOrders,
upgradeThreshold: level.upgradeThreshold,
visibleDelaySeconds: level.visibleDelaySeconds,
status: level.status === 'active' ? 'disabled' : 'active',
})
message.success(level.status === 'active' ? '等级已停用' : '等级已启用')
@@ -174,6 +177,12 @@ export default function LevelsPanel() {
},
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
{ title: '升级所需累计完成', dataIndex: 'upgradeThreshold' },
{
title: '可见延迟',
width: 110,
render: (_, row) =>
row.visibleDelaySeconds > 0 ? `${row.visibleDelaySeconds}` : '立即',
},
{
title: '状态',
width: 110,
@@ -217,6 +226,7 @@ export default function LevelsPanel() {
depositFreeAmount: 0,
maxActiveOrders: 1,
upgradeThreshold: 0,
visibleDelaySeconds: 20,
}}
onFinish={saveLevel}
>
@@ -247,6 +257,13 @@ export default function LevelsPanel() {
>
<InputNumber min={0} max={100000} />
</Form.Item>
<Form.Item
label="大厅可见延迟(秒)"
name="visibleDelaySeconds"
tooltip="发布到大厅后该等级打手延迟可见秒数,默认 VIP1=20 / VIP2=15 / VIP3=10 / VIP4=5 / VIP5=0,填 -1 使用默认"
>
<InputNumber min={-1} max={3600} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
@@ -231,7 +231,7 @@ export default function ProductRulesPanel() {
sharingUnitReward: 5,
sharingTotalAmount: 50,
fieldsText:
'gameAccount:游戏号\nserverName:区服\nroleName:角色名',
'gameId:游戏号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区',
}}
onFinish={saveRule}
>
@@ -382,7 +382,11 @@ export default function ProductRulesPanel() {
) : null
}
</Form.Item>
<Form.Item label="资料字段" name="fieldsText">
<Form.Item
label="资料字段"
name="fieldsText"
extra="每行一个字段:key:名称,下拉项用 # 分隔(如 system:系统#安卓,苹果)"
>
<Input.TextArea rows={4} />
</Form.Item>
<Button type="primary" htmlType="submit">
@@ -88,6 +88,7 @@ import {
getAcceptanceSubmittedAt,
getMaterialFields,
getMaterialDetailItems,
getMaterialScreenshots,
getRequirementFields,
renderDetailFieldValues,
renderMaterialSummary,
@@ -111,6 +112,7 @@ export default function WorkOrdersPanel() {
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [problemForm] = Form.useForm()
const [resolutionForm] = Form.useForm()
@@ -247,19 +249,24 @@ export default function WorkOrdersPanel() {
resolutionForm.resetFields()
}
async function submitMaterial(values: { fields?: Record<string, string> }) {
async function submitMaterial(values: {
fields?: Record<string, string>
screenshots?: UploadedFile[]
}) {
if (!materialOrder) return
const succeeded = await runAction(
() =>
submitAdminWorkOrderMaterial(
materialOrder.workOrderId,
values.fields || {},
values.screenshots || [],
),
'订单资料已保存',
)
if (!succeeded) return
setMaterialOrder(null)
materialForm.resetFields()
setMaterialScreenshots([])
}
async function submitSourceSync(values: { platformOrderId?: string }) {
@@ -279,6 +286,7 @@ export default function WorkOrdersPanel() {
function openMaterialModal(row: WorkOrder) {
setMaterialOrder(row)
materialForm.setFieldsValue({ fields: getMaterialFields(row) })
setMaterialScreenshots([])
}
function openDetailDrawer(row: WorkOrder) {
@@ -633,6 +641,12 @@ export default function WorkOrdersPanel() {
getMaterialDetailItems(detailOrder),
'当前还没有填写资料',
)}
<div style={{ marginTop: 12 }}>
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginTop: 8 }}>
<ImagePreviewList files={getMaterialScreenshots(detailOrder)} size={72} />
</div>
</div>
</Card>
<Card title="资料要求" size="small">
@@ -775,13 +789,31 @@ export default function WorkOrdersPanel() {
name={['fields', field.key]}
rules={
field.required
? [{ required: true, message: `请填写${field.label}` }]
? [{ required: true, message: `选择/填写${field.label}` }]
: undefined
}
>
<Input />
{field.type === 'select' && (field.options || []).length > 0 ? (
<Select
options={(field.options || []).map((option) => ({
label: option,
value: option,
}))}
/>
) : (
<Input />
)}
</Form.Item>
))}
<Form.Item label="买家主页截图">
<ImageUpload
scene="collect-material"
scope="admin"
maxCount={5}
value={materialScreenshots}
onChange={setMaterialScreenshots}
/>
</Form.Item>
</Form>
</Modal>
@@ -164,6 +164,26 @@ export function getAcceptanceSubmittedAt(order: WorkOrder): string | null {
return order.submittedAt || acceptanceSubmittedAt || null
}
export function getMaterialScreenshots(order: WorkOrder): UploadedFile[] {
const material = asRecord(order.material)
const collect = asRecord(material.collect)
const screenshots = Array.isArray(collect.screenshots) ? collect.screenshots : []
return screenshots
.map((item) => {
const file = asRecord(item)
const url = String(file.url || '').trim()
if (!url) return null
return {
objectKey: String(file.objectKey || file.object_key || '').trim(),
filename: String(file.originalFilename || file.filename || '').trim(),
url,
mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(),
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(),
} as UploadedFile
})
.filter((item): item is UploadedFile => Boolean(item))
}
export function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -1,9 +1,10 @@
import { SearchOutlined } from '@ant-design/icons'
import { App, Button, Card, Form, Input, Result, Space, Typography } from 'antd'
import { App, Button, Card, Form, Input, Radio, Result, Space, Typography } from 'antd'
import { useState } from 'react'
import ImageUpload from '@/components/files/ImageUpload'
import { lookupCollectOrder, submitCollectOrder } from '@/services/worker'
import type { CollectLookupOrder } from '@/types/worker-platform'
import type { CollectLookupOrder, UploadedFile } from '@/types/worker-platform'
export default function CollectPage() {
const { message } = App.useApp()
@@ -11,6 +12,7 @@ export default function CollectPage() {
const [lookupOrders, setLookupOrders] = useState<CollectLookupOrder[]>([])
const [lookupLoading, setLookupLoading] = useState(false)
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
const [complete, setComplete] = useState(false)
async function submitLookup(values: { orderNo: string }) {
@@ -35,6 +37,7 @@ export default function CollectPage() {
orderNo,
workOrderId: order.workOrderId,
fields: values,
screenshots: screenshotsByOrder[order.workOrderId] || [],
})
message.success(response.data.complete ? '当前商品资料已完善' : '当前商品资料已提交')
await refreshLookupOrders(orderNo, { allowCompleteWhenNotFound: true })
@@ -100,11 +103,34 @@ export default function CollectPage() {
key={field.key}
label={field.label}
name={field.key}
rules={field.required ? [{ required: true, message: `请填写${field.label}` }] : []}
rules={field.required ? [{ required: true, message: `选择/填写${field.label}` }] : []}
>
<Input />
{field.type === 'select' && (field.options || []).length > 0 ? (
<Radio.Group
options={(field.options || []).map((option) => ({
label: option,
value: option,
}))}
/>
) : (
<Input />
)}
</Form.Item>
))}
<Form.Item label="买家主页截图">
<ImageUpload
scene="collect-material"
scope="collect"
maxCount={5}
value={screenshotsByOrder[currentOrder.workOrderId] || []}
onChange={(files) =>
setScreenshotsByOrder((prev) => ({
...prev,
[currentOrder.workOrderId]: files,
}))
}
/>
</Form.Item>
<Button
type="primary"
htmlType="submit"
@@ -104,6 +104,7 @@ export default function WorkerHallPage() {
const orders = ordersQuery.data?.data.items || []
const pagination = ordersQuery.data?.data.pagination
const categories = ordersQuery.data?.data.categories || []
const visibleDelaySeconds = Number(ordersQuery.data?.data.visibleDelaySeconds || 0)
const worker = profileQuery.data?.data.worker
const activeCount = hallSummaryQuery.data?.activeCount || 0
const pendingAcceptanceCount = hallSummaryQuery.data?.pendingAcceptanceCount || 0
@@ -289,6 +290,14 @@ export default function WorkerHallPage() {
/>
) : null}
{visibleDelaySeconds > 0 ? (
<Alert
showIcon
type="info"
message={`新发布的订单将延迟 ${visibleDelaySeconds} 秒后对本等级可见(高等级打手优先)`}
/>
) : null}
{ordersQuery.isLoading ? (
<div className="worker-hall-loading">
<Spin size="large" />
@@ -1,5 +1,6 @@
import { apiDelete, apiGet, apiPost } from '@/lib/http'
import type {
UploadedFile,
WorkCategory,
WorkOrder,
WorkOrderShare,
@@ -62,6 +63,7 @@ export function saveAdminWorkerLevel(payload: {
depositFreeAmount?: number
maxActiveOrders?: number
upgradeThreshold?: number
visibleDelaySeconds?: number
status?: string
}) {
return apiPost<{ level: WorkerLevel }>(
@@ -203,10 +205,11 @@ export function syncAdminWorkerOrdersFromSource(payload: {
export function submitAdminWorkOrderMaterial(
workOrderId: number,
fields: Record<string, string>,
screenshots?: UploadedFile[],
) {
return apiPost<{ order: WorkOrder; complete: boolean }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/material`,
{ fields },
{ fields, screenshots },
)
}
+4 -2
View File
@@ -2,7 +2,7 @@ import { apiPostForm } from '@/lib/http'
import type { UploadedFile } from '@/types/worker-platform'
import { optimizeImageForUpload } from '@/utils/image-upload'
type UploadScope = 'admin' | 'worker'
type UploadScope = 'admin' | 'worker' | 'collect'
export async function uploadFile(
file: File,
@@ -16,7 +16,9 @@ export async function uploadFile(
const endpoint =
scope === 'admin'
? '/api/v1/admin/files/upload'
: '/api/v1/worker/files/upload'
: scope === 'collect'
? '/api/v1/collect/upload'
: '/api/v1/worker/files/upload'
const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form)
return response.data.file
}
+1
View File
@@ -143,6 +143,7 @@ export function submitCollectOrder(payload: {
orderNo: string
workOrderId?: number
fields: Record<string, string>
screenshots?: UploadedFile[]
}) {
return apiPost<{ complete: boolean; order: Record<string, unknown> }>(
'/api/v1/collect/submit',
@@ -19,6 +19,7 @@ export type WorkerUser = {
depositFreeAmount: number
maxActiveOrders: number
upgradeThreshold: number
visibleDelaySeconds: number
}
}
wallet: {
@@ -112,6 +113,7 @@ export type WorkerLevel = {
depositFreeAmount: number
maxActiveOrders: number
upgradeThreshold: number
visibleDelaySeconds: number
createdAt: string
updatedAt: string
}
@@ -262,12 +264,15 @@ export type WorkerHallOrdersResponse = {
items: WorkOrder[]
pagination: Pagination
categories: WorkCategory[]
visibleDelaySeconds?: number
}
export type CollectField = {
key: string
label: string
required: boolean
type?: 'text' | 'select'
options?: string[]
}
export type CollectLookupOrder = {