资料字段支持下拉选项与买家截图,大厅按 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
@@ -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,
+20
View File
@@ -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,
},