feat(collect): 补资料查询仅限待完善并优化页面展示
- 公开查询只返回待完善工单,待分配/已验收等状态提示「该订单当前无需补充资料」 - 404 失败查询按 IP 冷却 2 秒,成功查询不计数;前端显示「查询过于频繁」 - 提交成功后本地展示成功页,刷新后按严格模式提示无需补充资料 - 收敛补资料页重复提示,移除状态标签与工单号,商品与订单号合并展示 - 修复 React StrictMode 下刷新重复请求查询接口的问题
This commit is contained in:
@@ -2,7 +2,11 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js'
|
||||
import {
|
||||
createFailureCooldownMiddleware,
|
||||
createRateLimitMiddleware,
|
||||
resetRateLimitBucketsForTest,
|
||||
} from './rate-limit.js'
|
||||
|
||||
test('createRateLimitMiddleware allows requests within the window', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
@@ -96,13 +100,13 @@ test('createRateLimitMiddleware can cap an IP across different accounts', () =>
|
||||
assert.equal(response.statusCode, 429)
|
||||
})
|
||||
|
||||
function createMockRequest(): Request {
|
||||
function createMockRequest(ip = '127.0.0.1'): Request {
|
||||
return {
|
||||
ip: '127.0.0.1',
|
||||
ip,
|
||||
originalUrl: '/test',
|
||||
headers: {},
|
||||
socket: {
|
||||
remoteAddress: '127.0.0.1',
|
||||
remoteAddress: ip,
|
||||
},
|
||||
} as Request
|
||||
}
|
||||
@@ -137,10 +141,97 @@ function createMockResponse(): Response & {
|
||||
}
|
||||
}
|
||||
|
||||
type MockResponseWithEvents = Response & {
|
||||
statusCode: number
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
emit: (event: string) => void
|
||||
}
|
||||
|
||||
function createMockResponseWithFinish(): MockResponseWithEvents {
|
||||
const listeners: Record<string, Array<() => void>> = {}
|
||||
const res = createMockResponse() as MockResponseWithEvents
|
||||
res.on = (event, handler) => {
|
||||
const handlers = listeners[event] || []
|
||||
handlers.push(handler)
|
||||
listeners[event] = handlers
|
||||
return res
|
||||
}
|
||||
res.emit = (event: string) => {
|
||||
for (const handler of listeners[event] || []) handler()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
test('failure cooldown 只对 404 生效,按 IP 隔离且到期恢复', async () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createFailureCooldownMiddleware({
|
||||
scope: 'test:failure',
|
||||
cooldownMs: 60,
|
||||
})
|
||||
const req = createMockRequest('10.0.0.1')
|
||||
const first = createMockResponseWithFinish()
|
||||
let nextCount = 0
|
||||
limiter(req, first, () => {
|
||||
nextCount += 1
|
||||
})
|
||||
first.status(404).json({ code: 1 })
|
||||
first.emit('finish')
|
||||
|
||||
const blocked = createMockResponseWithFinish()
|
||||
limiter(req, blocked, () => {
|
||||
nextCount += 1
|
||||
})
|
||||
assert.equal(nextCount, 1)
|
||||
assert.equal(blocked.statusCode, 429)
|
||||
assert.equal(blocked.headers['Retry-After'], '1')
|
||||
assert.equal(blocked.body.errorCode, 'rate_limited')
|
||||
|
||||
const otherIp = createMockResponseWithFinish()
|
||||
limiter(createMockRequest('10.0.0.2'), otherIp, () => {
|
||||
nextCount += 1
|
||||
})
|
||||
assert.equal(nextCount, 2)
|
||||
assert.equal(otherIp.statusCode, 200)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
const recovered = createMockResponseWithFinish()
|
||||
limiter(req, recovered, () => {
|
||||
nextCount += 1
|
||||
})
|
||||
assert.equal(nextCount, 3)
|
||||
assert.equal(recovered.statusCode, 200)
|
||||
})
|
||||
|
||||
test('failure cooldown 不记录成功查询', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createFailureCooldownMiddleware({
|
||||
scope: 'test:failure-success',
|
||||
cooldownMs: 60_000,
|
||||
})
|
||||
const req = createMockRequest('10.0.0.3')
|
||||
const first = createMockResponseWithFinish()
|
||||
limiter(req, first, () => {})
|
||||
first.status(200).json({ code: 0 })
|
||||
first.emit('finish')
|
||||
|
||||
const second = createMockResponseWithFinish()
|
||||
let called = false
|
||||
limiter(req, second, () => {
|
||||
called = true
|
||||
})
|
||||
assert.equal(called, true)
|
||||
assert.equal(second.statusCode, 200)
|
||||
})
|
||||
|
||||
test('限流桶有数量硬上限:key 含用户可控字段时不能无限膨胀', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const src = await readFile(new URL('./rate-limit.ts', import.meta.url), 'utf8')
|
||||
// collect 路由的限流 key 里带请求体 orderNo(可被构造),只按过期清理挡不住高基数灌键
|
||||
assert.ok(src.includes('MAX_RATE_LIMIT_BUCKETS'), '缺少限流桶数量上限')
|
||||
assert.ok(src.includes('buckets.size > MAX_RATE_LIMIT_BUCKETS'), '必须在清理时按上限淘汰最老的桶')
|
||||
assert.ok(src.includes('MAX_FAILURE_COOLDOWN_KEYS'), '缺少失败冷却 key 数量上限')
|
||||
})
|
||||
|
||||
@@ -18,6 +18,14 @@ type RateLimitOptions = {
|
||||
onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void
|
||||
}
|
||||
|
||||
type FailureCooldownOptions = {
|
||||
scope: string
|
||||
cooldownMs: number
|
||||
/** 只对这些响应状态码触发冷却,默认 404。 */
|
||||
statusCodes?: number[]
|
||||
key?: RateLimitKeyResolver
|
||||
}
|
||||
|
||||
type RateLimitBucket = {
|
||||
count: number
|
||||
resetAt: number
|
||||
@@ -28,6 +36,11 @@ let lastCleanupAt = 0
|
||||
/** 限流桶数量硬上限:key 含请求体字段时可被构造成高基数,必须兜底防止内存无限增长。 */
|
||||
const MAX_RATE_LIMIT_BUCKETS = 50_000
|
||||
|
||||
/** 失败冷却:仅记录指定状态码(默认 404)后的 IP,冷却期内直接拒绝。 */
|
||||
const failureCooldowns = new Map<string, number>()
|
||||
let lastFailureCleanupAt = 0
|
||||
const MAX_FAILURE_COOLDOWN_KEYS = 50_000
|
||||
|
||||
export function createRateLimitMiddleware({
|
||||
scope,
|
||||
windowMs,
|
||||
@@ -82,9 +95,54 @@ export function createRateLimitMiddleware({
|
||||
}
|
||||
}
|
||||
|
||||
export function createFailureCooldownMiddleware({
|
||||
scope,
|
||||
cooldownMs,
|
||||
statusCodes = [404],
|
||||
key = defaultRateLimitKey,
|
||||
}: FailureCooldownOptions) {
|
||||
const normalizedScope = String(scope || 'failure-cooldown').trim() || 'failure-cooldown'
|
||||
const normalizedCooldownMs = Math.max(0, Number(cooldownMs || 0))
|
||||
const trackedStatusCodes = new Set(statusCodes.map((statusCode) => Number(statusCode)))
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const now = Date.now()
|
||||
cleanupExpiredFailureCooldowns(now)
|
||||
|
||||
const bucketKey = `${normalizedScope}:${key(req)}`
|
||||
const cooldownUntil = failureCooldowns.get(bucketKey) || 0
|
||||
if (cooldownUntil > now) {
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((cooldownUntil - now) / 1000))
|
||||
res.setHeader('Retry-After', String(retryAfterSeconds))
|
||||
|
||||
const message = '查询过于频繁,请稍后再试'
|
||||
const error = createHttpError(message, {
|
||||
statusCode: 429,
|
||||
errorCode: 'rate_limited',
|
||||
})
|
||||
res.status(429).json(buildErrorPayload(error, message))
|
||||
return
|
||||
}
|
||||
|
||||
// 响应结束后再判断:只有失败(默认 404)才进入冷却,正常查询不计数。
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
failureCooldowns.delete(bucketKey)
|
||||
return
|
||||
}
|
||||
if (!trackedStatusCodes.has(Number(res.statusCode))) return
|
||||
failureCooldowns.set(bucketKey, Date.now() + normalizedCooldownMs)
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRateLimitBucketsForTest(): void {
|
||||
buckets.clear()
|
||||
failureCooldowns.clear()
|
||||
lastCleanupAt = 0
|
||||
lastFailureCleanupAt = 0
|
||||
}
|
||||
|
||||
export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver {
|
||||
@@ -111,6 +169,29 @@ function normalizeKeyPart(value: unknown): string {
|
||||
return normalized || 'unknown'
|
||||
}
|
||||
|
||||
function cleanupExpiredFailureCooldowns(now: number): void {
|
||||
if (now - lastFailureCleanupAt < 60_000) {
|
||||
return
|
||||
}
|
||||
|
||||
lastFailureCleanupAt = now
|
||||
for (const [key, cooldownUntil] of failureCooldowns.entries()) {
|
||||
if (cooldownUntil <= now) {
|
||||
failureCooldowns.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (failureCooldowns.size > MAX_FAILURE_COOLDOWN_KEYS) {
|
||||
const overflow = failureCooldowns.size - MAX_FAILURE_COOLDOWN_KEYS
|
||||
let removed = 0
|
||||
for (const key of failureCooldowns.keys()) {
|
||||
failureCooldowns.delete(key)
|
||||
removed += 1
|
||||
if (removed >= overflow) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupExpiredBuckets(now: number): void {
|
||||
if (now - lastCleanupAt < 60_000) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
createFailureCooldownMiddleware,
|
||||
createRateLimitMiddleware,
|
||||
getBodyFieldRateLimitKey,
|
||||
getParamRateLimitKey,
|
||||
@@ -39,6 +40,12 @@ const materialLinkReadIpRateLimit = createRateLimitMiddleware({
|
||||
max: 300,
|
||||
})
|
||||
|
||||
/** 查询失败(404)后按 IP 冷却 2 秒;正常查询不计数,避免误伤用户。 */
|
||||
const materialLinkReadFailureCooldown = createFailureCooldownMiddleware({
|
||||
scope: 'collect:link:read:fail:ip',
|
||||
cooldownMs: 2_000,
|
||||
})
|
||||
|
||||
const materialLinkWriteRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:write',
|
||||
windowMs: 60_000,
|
||||
@@ -108,6 +115,7 @@ router.post(
|
||||
|
||||
router.get(
|
||||
'/link/:code',
|
||||
materialLinkReadFailureCooldown,
|
||||
materialLinkReadIpRateLimit,
|
||||
materialLinkReadRateLimit,
|
||||
createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.code || '')), {
|
||||
|
||||
@@ -3,10 +3,12 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatPublicMaterialStatus,
|
||||
pickPendingMaterialOrders,
|
||||
pickSubmittedMaterialFields,
|
||||
resolveMaterialResetBlockReason,
|
||||
resolvePublicMaterialOrderState,
|
||||
} from './work-order-material-link-service.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
|
||||
test('公开补资料:只有待完善且未指派/无份额可提交', () => {
|
||||
const editable = resolvePublicMaterialOrderState({
|
||||
@@ -128,6 +130,22 @@ test('客服重置补资料:只允许待分配、未指派、无份额、未
|
||||
)
|
||||
})
|
||||
|
||||
test('公开补资料:只查询待完善工单,其他状态一律不返回', () => {
|
||||
const orders = [
|
||||
{ status: 'pending_material' },
|
||||
{ status: 'unassigned' },
|
||||
{ status: 'open' },
|
||||
{ status: 'accepted' },
|
||||
{ status: 'cancelled' },
|
||||
] as WorkOrderRow[]
|
||||
|
||||
assert.deepEqual(
|
||||
pickPendingMaterialOrders(orders).map((order) => order.status),
|
||||
['pending_material'],
|
||||
)
|
||||
assert.deepEqual(pickPendingMaterialOrders([]), [])
|
||||
})
|
||||
|
||||
test('公开补资料:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
|
||||
const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], {
|
||||
gameId: '123',
|
||||
|
||||
@@ -232,12 +232,21 @@ async function resolvePublicMaterialTarget(
|
||||
return orders[0]!
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页只允许查询待完善工单。
|
||||
*
|
||||
* 待分配、待抢单、代练中、已验收、已取消等状态一律不返回,
|
||||
* 避免客户用订单号看到已完成订单的商品与状态。
|
||||
*/
|
||||
export function pickPendingMaterialOrders(orders: WorkOrderRow[]): WorkOrderRow[] {
|
||||
return orders.filter((order) => order.status === WORK_ORDER_STATUS.PENDING_MATERIAL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页按平台订单号读取补资料内容。
|
||||
*
|
||||
* 不再要求客服先生成链接;只要订单号存在,就返回该订单号下所有工单的
|
||||
* 状态与准入信息。只有可提交的待完善工单返回字段/已填值/截图,其他状态
|
||||
* 只返回商品、工单号、状态和客服提示。
|
||||
* 不再要求客服先生成链接;订单号存在且仍有待完善工单时才返回内容。
|
||||
* 只返回待完善工单;已提交(待分配)及其他状态统一提示无需补充资料。
|
||||
*/
|
||||
export async function getPublicWorkOrderMaterialLink(platformOrderId: string) {
|
||||
const code = String(platformOrderId || '').trim()
|
||||
@@ -256,7 +265,15 @@ export async function getPublicWorkOrderMaterialLink(platformOrderId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const publicOrders = orders.map(mapPublicMaterialOrder)
|
||||
const pendingOrders = pickPendingMaterialOrders(orders)
|
||||
if (pendingOrders.length === 0) {
|
||||
throw createHttpError('该订单当前无需补充资料', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_pending',
|
||||
})
|
||||
}
|
||||
|
||||
const publicOrders = pendingOrders.map(mapPublicMaterialOrder)
|
||||
return {
|
||||
platformOrderId: code,
|
||||
orders: publicOrders,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { CheckCircleFilled, LoadingOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
@@ -47,33 +47,28 @@ function resolveErrorTitle(code: string): string {
|
||||
if (code === 'material_link_not_found' || code === 'material_link_work_order_invalid') {
|
||||
return '订单号无效'
|
||||
}
|
||||
if (code === 'material_link_not_pending') return '该订单当前无需补充资料'
|
||||
if (code === 'rate_limited') return '查询过于频繁'
|
||||
if (code === 'material_link_work_order_required') return '请选择要补充的商品'
|
||||
if (code === 'material_link_order_status_invalid') return '订单状态已变化'
|
||||
return '暂时无法打开页面'
|
||||
}
|
||||
|
||||
const STATUS_TAG_COLORS: Record<string, string> = {
|
||||
pending_material: 'orange',
|
||||
unassigned: 'blue',
|
||||
open: 'cyan',
|
||||
in_progress: 'processing',
|
||||
pending_acceptance: 'purple',
|
||||
problem: 'red',
|
||||
accepted: 'green',
|
||||
cancelled: 'default',
|
||||
}
|
||||
|
||||
function resolveStatusColor(status: string): string {
|
||||
return STATUS_TAG_COLORS[status] || 'default'
|
||||
/** 公开页不展示「待分配」字样:已提交统一用「资料已提交」表达。 */
|
||||
function resolveMaterialOrderMessage(order: CollectMaterialLinkOrder): string {
|
||||
if (order.blockedReason === 'already_submitted') {
|
||||
return '资料已提交;如需修改请联系客服。'
|
||||
}
|
||||
return order.message || '当前不能修改资料,如需修改请联系客服。'
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户侧补资料页。
|
||||
*
|
||||
* 只要知道平台订单号就能打开:一个订单号下可能有多个商品工单,页面按 orders
|
||||
* 渲染多张卡片。只有「待完善 + 未指派 + 无份额」的工单返回表单,其余工单只展示
|
||||
* 商品、工单号、状态与客服提示,不暴露已填资料和截图。
|
||||
* 提交成功后工单进入「待分配」,同一工单不能再提交,需联系客服重置。
|
||||
* 只查询「待完善」工单;其他状态(待分配、已验收等)统一提示无需补充资料,
|
||||
* 不展示商品、订单号等信息。页面按 orders 渲染多张卡片,只有未指派、无份额的
|
||||
* 待完善工单返回表单。提交成功后本地展示成功页;刷新后因工单已变为待分配,
|
||||
* 会提示「该订单当前无需补充资料」。
|
||||
*/
|
||||
export default function CollectMaterialLinkPage() {
|
||||
const { message } = App.useApp()
|
||||
@@ -88,7 +83,11 @@ export default function CollectMaterialLinkPage() {
|
||||
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
|
||||
const submittingRef = useRef(false)
|
||||
const requestSeqRef = useRef(0)
|
||||
/** React StrictMode 开发环境会重复执行 mount effect,这里按订单号去重,避免刷新时打两次查询接口。 */
|
||||
const loadedCodeRef = useRef<string | null>(null)
|
||||
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
|
||||
const [submittedSuccess, setSubmittedSuccess] = useState(false)
|
||||
const [submittedNames, setSubmittedNames] = useState<string[]>([])
|
||||
|
||||
/**
|
||||
* 读取订单资料状态。
|
||||
@@ -101,6 +100,13 @@ export default function CollectMaterialLinkPage() {
|
||||
const seq = requestSeqRef.current + 1
|
||||
requestSeqRef.current = seq
|
||||
|
||||
if (!options.silent) {
|
||||
// 新订单号/刷新页面时清空上一次提交成功状态;提交后不再回查
|
||||
// (严格模式只允许查询待完善订单,回查会变成「无需补充资料」)。
|
||||
setSubmittedSuccess(false)
|
||||
setSubmittedNames([])
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setInfo(null)
|
||||
setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE })
|
||||
@@ -145,8 +151,10 @@ export default function CollectMaterialLinkPage() {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedCodeRef.current === code) return
|
||||
loadedCodeRef.current = code
|
||||
void loadInfo()
|
||||
}, [loadInfo])
|
||||
}, [code, loadInfo])
|
||||
|
||||
async function handleSubmit(order: CollectMaterialLinkOrder, values: Record<string, string>) {
|
||||
if (!code) return
|
||||
@@ -160,8 +168,19 @@ export default function CollectMaterialLinkPage() {
|
||||
fields: values,
|
||||
screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [],
|
||||
})
|
||||
message.success('资料已提交,订单已进入待分配')
|
||||
await loadInfo({ silent: true })
|
||||
message.success('提交成功')
|
||||
const submittedName = String(order.productName || '').trim() || '订单商品'
|
||||
setSubmittedNames((previous) => [...previous, submittedName])
|
||||
const remainingOrders = (info?.orders || []).filter(
|
||||
(item) => item.workOrderId !== order.workOrderId,
|
||||
)
|
||||
if (remainingOrders.length > 0) {
|
||||
await loadInfo({ silent: true })
|
||||
} else {
|
||||
// 严格模式只查询待完善订单:最后一个商品提交后不再回查,
|
||||
// 直接本地展示成功页,避免刷新成「无需补充资料」。
|
||||
setSubmittedSuccess(true)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorCode = resolveErrorCode(error)
|
||||
message.error(resolveErrorMessage(error))
|
||||
@@ -181,6 +200,9 @@ export default function CollectMaterialLinkPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const emptyStateMessage = '该订单当前不可修改资料,如需修改请联系客服。'
|
||||
const submittedProductNames = submittedNames.join('、')
|
||||
|
||||
return (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
@@ -191,56 +213,63 @@ export default function CollectMaterialLinkPage() {
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<Result
|
||||
status="warning"
|
||||
status={loadError.code === 'material_link_not_pending' ? 'info' : 'warning'}
|
||||
title={resolveErrorTitle(loadError.code)}
|
||||
subTitle={loadError.message || NOT_FOUND_MESSAGE}
|
||||
subTitle={
|
||||
loadError.code === 'material_link_not_pending'
|
||||
? undefined
|
||||
: loadError.code === 'rate_limited'
|
||||
? '请稍后再试'
|
||||
: loadError.message || NOT_FOUND_MESSAGE
|
||||
}
|
||||
extra={
|
||||
<Link to="/supplement">
|
||||
<Button>重新输入订单号</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : submittedSuccess ? (
|
||||
<div className="collect-link-success">
|
||||
<CheckCircleFilled className="collect-link-success-icon" />
|
||||
<Title level={4} className="collect-link-success-title">
|
||||
提交成功
|
||||
</Title>
|
||||
<Text type="secondary">资料已提交;如需修改请联系客服。</Text>
|
||||
<div className="collect-link-success-products">
|
||||
<span>本次提交</span>
|
||||
<strong>{submittedProductNames || '订单资料'}</strong>
|
||||
</div>
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
本页面仅用于补充订单资料,请妥善保管订单号。
|
||||
</Text>
|
||||
</div>
|
||||
) : info ? (
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<div>
|
||||
<Title level={3} className="collect-link-title">
|
||||
订单资料补充
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
{info.pendingCount > 0
|
||||
? '请补充以下资料,提交后客服会尽快处理。'
|
||||
: '以下为该订单当前的资料状态,如需修改请联系客服。'}
|
||||
</Text>
|
||||
</div>
|
||||
{info.pendingCount > 0 ? (
|
||||
<Text type="secondary">请补充以下资料,提交后客服会尽快处理。</Text>
|
||||
) : null}
|
||||
|
||||
{info.pendingCount > 1 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前订单有 ${info.pendingCount} 个待完善商品,请逐个提交`}
|
||||
description="每个商品的资料仅可提交一次,提交成功后该商品进入待分配。"
|
||||
message={`当前订单有 ${info.pendingCount} 个商品待补充资料,请逐个提交`}
|
||||
description="每个商品的资料仅可提交一次,提交成功后不能再修改。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{info.pendingCount === 0 ? (
|
||||
<Alert type="info" showIcon message="该订单当前不可修改资料,如需修改请联系客服" />
|
||||
<Alert type="warning" showIcon message={emptyStateMessage} />
|
||||
) : null}
|
||||
|
||||
{info.orders.map((order) => (
|
||||
<Card
|
||||
key={order.workOrderId}
|
||||
className="collect-link-order-card"
|
||||
size="small"
|
||||
title={order.productName || '订单商品'}
|
||||
extra={<Tag color={resolveStatusColor(order.status)}>{order.statusLabel}</Tag>}
|
||||
>
|
||||
<Card key={order.workOrderId} className="collect-link-order-card" size="small">
|
||||
<Space direction="vertical" size={12} className="full-width">
|
||||
<div className="collect-link-meta">
|
||||
<div>
|
||||
<span>工单号</span>
|
||||
<strong>{order.workOrderNo || '-'}</strong>
|
||||
<div className="collect-link-order-head">
|
||||
<div className="collect-link-order-name">
|
||||
<span>{order.productName || '订单商品'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="collect-link-order-no">
|
||||
<span>订单号</span>
|
||||
<strong>{order.platformOrderId || '-'}</strong>
|
||||
</div>
|
||||
@@ -253,7 +282,7 @@ export default function CollectMaterialLinkPage() {
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该商品资料仅可提交一次"
|
||||
description="提交成功后该商品进入待分配,不能再修改;如需修改请联系客服。"
|
||||
description="提交成功后不能再修改;如需修改请联系客服。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -274,13 +303,13 @@ export default function CollectMaterialLinkPage() {
|
||||
onSubmit={(values) => handleSubmit(order, values)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
) : info.pendingCount > 0 ? (
|
||||
<Alert
|
||||
type={order.blockedReason === 'already_submitted' ? 'info' : 'warning'}
|
||||
showIcon
|
||||
message={order.message || '当前不能修改资料,如需修改请联系客服。'}
|
||||
message={resolveMaterialOrderMessage(order)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -508,30 +508,42 @@
|
||||
margin: 0 0 4px !important;
|
||||
}
|
||||
|
||||
.collect-link-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 20px;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
/* 商品名与订单号放在同一个信息头里,避免标题和灰色信息块分离 */
|
||||
.collect-link-order-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.collect-link-meta > div {
|
||||
.collect-link-order-name {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.collect-link-meta span {
|
||||
.collect-link-order-no {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collect-link-meta strong {
|
||||
.collect-link-order-no strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -544,6 +556,56 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 提交成功:居中的成功状态,不再展示订单号 */
|
||||
.collect-link-success {
|
||||
min-height: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.collect-link-success-icon {
|
||||
font-size: 64px;
|
||||
color: #22c55e;
|
||||
filter: drop-shadow(0 10px 20px rgba(34, 197, 94, 0.18));
|
||||
}
|
||||
|
||||
.collect-link-success-title {
|
||||
margin: 2px 0 0 !important;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.collect-link-success-products {
|
||||
max-width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 12px 22px;
|
||||
border: 1px solid #dcfce7;
|
||||
border-radius: 12px;
|
||||
background: #f0fdf4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.collect-link-success-products span {
|
||||
color: #16a34a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collect-link-success-products strong {
|
||||
color: #166534;
|
||||
font-size: 15px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.collect-link-success .collect-link-footnote {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.collect-link-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -569,11 +631,6 @@
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.collect-link-meta {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.collect-link-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
@@ -591,6 +648,19 @@
|
||||
.collect-link-card .ant-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.collect-link-success {
|
||||
min-height: 320px;
|
||||
padding: 16px 4px 4px;
|
||||
}
|
||||
|
||||
.collect-link-success-icon {
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
.collect-link-success-products {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 兑换中心 (Redeem Page) ===== */
|
||||
|
||||
Reference in New Issue
Block a user