资料字段支持下拉选项与买家截图,大厅按 VIP 等级延迟可见
资料字段: - CollectField 支持 type(text/select) 与 options,规则字段语法 key:名称#选项1,选项2 - 默认字段改为游戏编号/游戏昵称/系统(安卓,苹果)/区服(QQ区,微信区),旧默认模板字段自动替换为新版 - 采集页与管理端补资料支持单选下拉与买家主页截图上传(新增公开上传接口) - 管理端工单详情展示买家截图 VIP 延迟可见: - 等级权限新增 visibleDelaySeconds(默认 VIP1=20s~VIP5=0s,可后台配置) - 大厅按等级延迟过滤新发布工单,页面提示延迟规则
This commit is contained in:
@@ -190,6 +190,7 @@ export type ListInput = {
|
||||
workerId?: number
|
||||
categoryId?: number
|
||||
workerSharingId?: number
|
||||
visibleAfterIso?: string
|
||||
}
|
||||
|
||||
export type ProductRuleListInput = {
|
||||
|
||||
@@ -565,6 +565,7 @@ export async function listWorkOrders({
|
||||
workerId = 0,
|
||||
categoryId = 0,
|
||||
workerSharingId = 0,
|
||||
visibleAfterIso = '',
|
||||
}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> {
|
||||
const { whereClause, params } = buildWorkOrderWhere({
|
||||
status,
|
||||
@@ -572,6 +573,7 @@ export async function listWorkOrders({
|
||||
workerId,
|
||||
categoryId,
|
||||
workerSharingId,
|
||||
visibleAfterIso,
|
||||
})
|
||||
const totalResult = await query<{ total: number }>(
|
||||
`
|
||||
@@ -1326,6 +1328,7 @@ function buildWorkOrderWhere({
|
||||
workerId = 0,
|
||||
categoryId = 0,
|
||||
workerSharingId = 0,
|
||||
visibleAfterIso = '',
|
||||
}: ListInput) {
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
@@ -1357,6 +1360,10 @@ function buildWorkOrderWhere({
|
||||
))`,
|
||||
)
|
||||
}
|
||||
if (visibleAfterIso) {
|
||||
params.push(visibleAfterIso)
|
||||
filters.push(`(wo.published_at IS NULL OR wo.published_at <= $${params.length})`)
|
||||
}
|
||||
return {
|
||||
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
||||
params,
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
collectLookupWorkOrder,
|
||||
collectSubmitWorkOrder,
|
||||
} from '../services/worker-platform/index.js'
|
||||
import { uploadFileAsset } from '../services/file-storage/file-storage-service.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
import { uploadSingleFile } from './file-upload.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -36,6 +38,24 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/upload',
|
||||
collectRateLimit,
|
||||
uploadSingleFile,
|
||||
createRouteHandler((req) => {
|
||||
return uploadFileAsset({
|
||||
file: req.file,
|
||||
scene: req.body?.scene || 'collect-material',
|
||||
uploaderType: 'collect',
|
||||
uploaderId: String(req.body?.orderNo || 'anonymous'),
|
||||
})
|
||||
}, {
|
||||
successMessage: '文件已上传',
|
||||
errorMessage: '文件上传失败',
|
||||
scope: '[collect/upload]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ import {
|
||||
saveWorkerFinanceConfig,
|
||||
} from './worker-finance-config-service.js'
|
||||
|
||||
import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js'
|
||||
import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, normalizeUploadedFiles, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js'
|
||||
import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker } from './worker-service.js'
|
||||
|
||||
export async function listAdminWorkerLevels() {
|
||||
@@ -146,6 +146,7 @@ export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
||||
),
|
||||
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 1),
|
||||
upgradeThreshold: normalizeInteger(payload.upgradeThreshold, 0),
|
||||
visibleDelaySeconds: normalizeInteger(payload.visibleDelaySeconds, -1),
|
||||
}
|
||||
const level = await upsertWorkerLevel({
|
||||
levelKey,
|
||||
@@ -562,10 +563,12 @@ export async function submitAdminWorkOrderMaterial(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete,
|
||||
source: 'admin',
|
||||
|
||||
@@ -157,9 +157,26 @@ export function resolveWorkerPermissions(worker: WorkerUserRow) {
|
||||
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
|
||||
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
||||
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
|
||||
visibleDelaySeconds: resolveVisibleDelaySeconds(
|
||||
worker.level_key || '',
|
||||
permission.visibleDelaySeconds,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveVisibleDelaySeconds(levelKey: string, configured?: unknown): number {
|
||||
const configuredValue = normalizeInteger(configured, -1)
|
||||
if (configuredValue >= 0) return configuredValue
|
||||
const defaultDelays: Record<string, number> = {
|
||||
vip1: 20,
|
||||
vip2: 15,
|
||||
vip3: 10,
|
||||
vip4: 5,
|
||||
vip5: 0,
|
||||
}
|
||||
return defaultDelays[String(levelKey || '').toLowerCase()] ?? 0
|
||||
}
|
||||
|
||||
export function resolveFreezeDepositAmount(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
@@ -182,6 +199,10 @@ export function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
|
||||
depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0),
|
||||
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
||||
upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0),
|
||||
visibleDelaySeconds: resolveVisibleDelaySeconds(
|
||||
level.level_key,
|
||||
permission.visibleDelaySeconds,
|
||||
),
|
||||
createdAt: level.created_at,
|
||||
updatedAt: level.updated_at,
|
||||
}
|
||||
@@ -434,6 +455,15 @@ export function resolveRequirementFields(workOrder: WorkOrderRow) {
|
||||
return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields)
|
||||
}
|
||||
|
||||
export type RequirementField = {
|
||||
key: string
|
||||
label: string
|
||||
required: boolean
|
||||
type: 'text' | 'select'
|
||||
options: string[]
|
||||
mockValue: string
|
||||
}
|
||||
|
||||
export function normalizeRequirementFields(value: unknown) {
|
||||
const rawFields = Array.isArray(value) ? value : []
|
||||
const fields = rawFields
|
||||
@@ -442,24 +472,37 @@ export function normalizeRequirementFields(value: unknown) {
|
||||
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
|
||||
const key = String(source.key || '').trim()
|
||||
if (!key) return null
|
||||
const type = source.type === 'select' ? 'select' : 'text'
|
||||
const options = Array.isArray(source.options)
|
||||
? source.options.map((option) => String(option || '').trim()).filter(Boolean)
|
||||
: []
|
||||
return {
|
||||
key,
|
||||
label: String(source.label || key).trim(),
|
||||
required: source.required !== false,
|
||||
type,
|
||||
options,
|
||||
mockValue: String(source.mockValue || '').trim(),
|
||||
}
|
||||
})
|
||||
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
|
||||
Boolean(item),
|
||||
)
|
||||
.filter((item): item is RequirementField => Boolean(item))
|
||||
|
||||
return fields.length > 0
|
||||
? fields
|
||||
: [
|
||||
{ key: 'gameAccount', label: '游戏账号', required: true, mockValue: 'test_account' },
|
||||
{ key: 'serverName', label: '区服', required: true, mockValue: '安卓一区' },
|
||||
{ key: 'roleName', label: '角色名', required: true, mockValue: '测试角色' },
|
||||
]
|
||||
if (fields.length > 0 && !isDefaultTemplateRequirementFields(fields)) {
|
||||
return fields
|
||||
}
|
||||
return [
|
||||
{ key: 'gameId', label: '游戏编号', required: true, type: 'text', options: [], mockValue: 'test_uid' },
|
||||
{ key: 'gameNickname', label: '游戏昵称', required: true, type: 'text', options: [], mockValue: '测试角色' },
|
||||
{ key: 'system', label: '系统', required: true, type: 'select', options: ['安卓', '苹果'], mockValue: '安卓' },
|
||||
{ key: 'serverZone', label: '区服', required: true, type: 'select', options: ['QQ区', '微信区'], mockValue: 'QQ区' },
|
||||
]
|
||||
}
|
||||
|
||||
function isDefaultTemplateRequirementFields(fields: RequirementField[]): boolean {
|
||||
if (fields.length === 0) return true
|
||||
const defaultKeys = new Set(['gameId', 'gameNickname', 'system', 'serverZone'])
|
||||
const legacyKeys = new Set(['gameAccount', 'serverName', 'roleName'])
|
||||
return fields.every((field) => defaultKeys.has(field.key) || legacyKeys.has(field.key))
|
||||
}
|
||||
|
||||
export function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
|
||||
@@ -494,23 +537,42 @@ export function normalizeRequirementFieldsText(value: unknown) {
|
||||
const [rawKey, ...labelParts] = text.split(delimiter)
|
||||
const key = String(rawKey || '').trim()
|
||||
if (!key) return null
|
||||
const labelWithOptions = labelParts.join(delimiter).trim() || key
|
||||
const [label, rawOptions] = splitFieldOptions(labelWithOptions)
|
||||
return {
|
||||
key,
|
||||
label: labelParts.join(delimiter).trim() || key,
|
||||
label: label || key,
|
||||
required: true,
|
||||
type: rawOptions.length > 0 ? 'select' : 'text',
|
||||
options: rawOptions,
|
||||
mockValue: '',
|
||||
}
|
||||
}
|
||||
const [plainLabel, plainOptions] = splitFieldOptions(text)
|
||||
return {
|
||||
key: `field${index + 1}`,
|
||||
label: text,
|
||||
label: plainLabel,
|
||||
required: true,
|
||||
type: plainOptions.length > 0 ? 'select' : 'text',
|
||||
options: plainOptions,
|
||||
mockValue: '',
|
||||
}
|
||||
})
|
||||
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
|
||||
Boolean(item),
|
||||
)
|
||||
.filter((item): item is RequirementField => Boolean(item))
|
||||
}
|
||||
|
||||
function splitFieldOptions(value: string): [string, string[]] {
|
||||
const text = String(value || '').trim()
|
||||
if (!text.includes('#')) {
|
||||
return [text, []]
|
||||
}
|
||||
const [label, ...optionParts] = text.split('#')
|
||||
const options = optionParts
|
||||
.join('#')
|
||||
.split(/[,,、]/)
|
||||
.map((option) => String(option || '').trim())
|
||||
.filter(Boolean)
|
||||
return [String(label || '').trim(), options]
|
||||
}
|
||||
|
||||
export function resolveMatchingProductRule(
|
||||
|
||||
@@ -641,15 +641,21 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const categoryId = normalizeOptionalId(query.categoryId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0)
|
||||
const visibleAfterIso =
|
||||
visibleDelaySeconds > 0
|
||||
? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString()
|
||||
: ''
|
||||
const { items, total } = await listWorkOrders({
|
||||
page,
|
||||
pageSize,
|
||||
status: WORK_ORDER_STATUS.OPEN,
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
categoryId: categoryId || 0,
|
||||
visibleAfterIso,
|
||||
})
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
const categories = (await listWorkCategories()).map(mapWorkCategory)
|
||||
const myShares = await listWorkerSharesByWorker(worker.id)
|
||||
const myShareByOrderId = new Map(
|
||||
@@ -663,6 +669,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
||||
),
|
||||
pagination: { page, pageSize, total },
|
||||
categories,
|
||||
visibleDelaySeconds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -996,10 +1003,12 @@ export async function collectSubmitWorkOrder(payload: JsonObject = {}) {
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
screenshots,
|
||||
submittedAt: now,
|
||||
complete,
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user