Files
order_site/apps/frontend/src/pages/public/OrderServicePage.tsx
T

712 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
CheckCircleFilled,
LoadingOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
DatePicker,
Form,
Image,
Input,
InputNumber,
Radio,
Result,
Select,
Space,
Spin,
Steps,
Tag,
Typography,
} from 'antd'
import dayjs, { type Dayjs } from 'dayjs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import ImageUpload from '@/components/files/ImageUpload'
import {
fetchOrderService,
submitOrderServiceMaterial,
uploadOrderServiceFile,
type OrderServiceInfo,
type OrderServiceItem,
} from '@/services/worker'
import type { UploadedFile } from '@/types/worker-platform'
import { normalizeOrderServiceInput } from '@/utils/order-service-link'
import { useOrderServicePageMeta } from './use-order-service-page-meta'
const { Text, Title } = Typography
const NOT_FOUND_MESSAGE = '订单号无效,请确认订单号是否正确'
const STATUS_REFRESH_INTERVAL_MS = 15_000
const STATUS_STEP_INDEX: Record<string, number> = {
pending_material: 0,
unassigned: 1,
open: 2,
in_progress: 3,
pending_acceptance: 4,
accepted: 4,
problem: 3,
}
const STATUS_STEP_ITEMS = [
{ title: '提交资料' },
{ title: '客服安排' },
{ title: '发布接单' },
{ title: '订单处理' },
{ title: '交付完成' },
]
const STATUS_TAG_COLORS: Record<string, string> = {
pending_material: 'orange',
unassigned: 'blue',
open: 'gold',
in_progress: 'processing',
pending_acceptance: 'purple',
accepted: 'success',
problem: 'error',
cancelled: 'default',
}
function resolveErrorCode(error: unknown) {
return String((error as { errorCode?: string })?.errorCode || '').trim()
}
function resolveErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败,请稍后重试'
}
function resolveErrorTitle(code: string) {
if (code === 'order_service_not_found') return '订单号无效'
if (code === 'rate_limited') return '查询过于频繁'
return '暂时无法打开页面'
}
export default function OrderServicePage() {
const { message, modal } = App.useApp()
const navigate = useNavigate()
const { orderNo: routeOrderNo } = useParams<{ orderNo?: string }>()
const orderNo = String(routeOrderNo || '').trim()
const [info, setInfo] = useState<OrderServiceInfo | null>(null)
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState<{ code: string; message: string } | null>(null)
const [submittedNames, setSubmittedNames] = useState<string[]>([])
const [lastSuccessText, setLastSuccessText] = useState('')
const [submittingId, setSubmittingId] = useState(0)
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
const submittingRef = useRef(false)
const confirmationOpenRef = useRef(false)
const requestSeqRef = useRef(0)
const loadedOrderRef = useRef('')
useOrderServicePageMeta(info?.items[0]?.title || '订单服务')
const load = useCallback(
async (options: { silent?: boolean } = {}) => {
const seq = requestSeqRef.current + 1
requestSeqRef.current = seq
if (!options.silent) {
setSubmittedNames([])
setLastSuccessText('')
setLoading(true)
}
if (!orderNo) {
setInfo(null)
setLoadError(null)
setLoading(false)
return
}
try {
const response = await fetchOrderService(orderNo)
if (seq !== requestSeqRef.current) return
setInfo(response.data)
setLoadError(null)
} catch (error) {
if (seq !== requestSeqRef.current) return
if (options.silent && info) return
setInfo(null)
setLoadError({
code: resolveErrorCode(error),
message: resolveErrorMessage(error) || NOT_FOUND_MESSAGE,
})
} finally {
if (seq === requestSeqRef.current && !options.silent) setLoading(false)
}
},
[info, orderNo],
)
useEffect(() => {
if (loadedOrderRef.current === orderNo) return
loadedOrderRef.current = orderNo
setScreenshotsByOrder({})
if (!orderNo) {
setInfo(null)
setLoadError(null)
return
}
void load()
}, [load, orderNo])
useEffect(() => {
if (!orderNo) return
const refresh = () => {
if (document.visibilityState === 'visible' && !submittingRef.current) {
void load({ silent: true })
}
}
const timer = window.setInterval(refresh, STATUS_REFRESH_INTERVAL_MS)
document.addEventListener('visibilitychange', refresh)
window.addEventListener('focus', refresh)
return () => {
window.clearInterval(timer)
document.removeEventListener('visibilitychange', refresh)
window.removeEventListener('focus', refresh)
}
}, [load, orderNo])
function handleSearch(values: { orderNo?: string }) {
const nextOrderNo = normalizeOrderServiceInput(values.orderNo)
if (!nextOrderNo) {
message.warning('请输入订单号')
return
}
void navigate(`/o/${encodeURIComponent(nextOrderNo)}`)
}
function handleSubmit(item: OrderServiceItem, values: Record<string, unknown>) {
if (!orderNo || submittingRef.current || confirmationOpenRef.current) return
const screenshots = screenshotsByOrder[item.workOrderId] ?? item.screenshots ?? []
const normalizedValues = normalizeFormFieldValues(item, values)
const screenshotRule = item.screenshotsRule
if (
screenshotRule?.enabled &&
screenshotRule.minCount > 0 &&
screenshots.length < screenshotRule.minCount
) {
message.error(`${screenshotRule.label}至少需要 ${screenshotRule.minCount} 张`)
return
}
const confirmation = item.materialConfirmation
if (!confirmation?.enabled) {
void submitMaterial(item, normalizedValues, screenshots)
return
}
confirmationOpenRef.current = true
modal.confirm({
title: confirmation.title || '提交资料确认',
content: (
<MaterialConfirmationSummary
item={item}
platformOrderId={orderNo}
values={normalizedValues}
screenshots={screenshots}
notice={
confirmation.content ||
'请确认资料填写准确无误。资料提交后不能修改,因个人资料填写错误导致的赠送错误,店铺概不负责!'
}
/>
),
okText: confirmation.confirmText || '确认提交',
cancelText: confirmation.cancelText || '返回检查',
centered: true,
maskClosable: false,
width: 560,
onOk: () => submitMaterial(item, normalizedValues, screenshots),
afterClose: () => {
confirmationOpenRef.current = false
},
})
}
async function submitMaterial(
item: OrderServiceItem,
fields: Record<string, string>,
screenshots: UploadedFile[],
) {
if (!orderNo || submittingRef.current) return
submittingRef.current = true
setSubmittingId(item.workOrderId)
try {
const response = await submitOrderServiceMaterial(orderNo, {
workOrderId: item.workOrderId,
fields,
screenshots,
})
setSubmittedNames((current) => [
...current,
item.productName || item.categoryName || '订单商品',
])
setLastSuccessText(response.data.successText || item.successText || '资料已提交')
await load({ silent: true })
} catch (error) {
message.error(resolveErrorMessage(error))
const code = resolveErrorCode(error)
if (
code === 'order_service_not_found' ||
code === 'material_link_already_submitted' ||
code === 'material_link_order_status_invalid'
) {
await load({ silent: true })
}
} finally {
submittingRef.current = false
setSubmittingId(0)
}
}
if (!orderNo) {
return (
<main className="collect-link-page">
<Card className="collect-link-card" bordered={false}>
<Space direction="vertical" size={16} className="full-width">
<div>
<Title level={3}>订单服务</Title>
<Text type="secondary">请输入下单时使用的订单号,补充资料或查看进度。</Text>
</div>
<Form layout="vertical" onFinish={handleSearch}>
<Form.Item
name="orderNo"
label="订单号"
rules={[{ required: true, whitespace: true, message: '请输入订单号' }]}
>
<Input
allowClear
autoComplete="off"
prefix={<SearchOutlined />}
placeholder="请输入订单号"
size="large"
/>
</Form.Item>
<Button block htmlType="submit" size="large" type="primary">
查询订单
</Button>
</Form>
</Space>
</Card>
</main>
)
}
const pageConfig = info?.items[0]
const pendingItems = (info?.items || []).filter((item) => item.canSubmit)
return (
<main className="collect-link-page">
<Card className="collect-link-card" bordered={false}>
{loading ? (
<div className="collect-link-state">
<Spin indicator={<LoadingOutlined spin />} size="large" />
<Text type="secondary">正在读取订单信息...</Text>
</div>
) : loadError ? (
<Result
status={loadError.code === 'rate_limited' ? 'warning' : 'info'}
title={resolveErrorTitle(loadError.code)}
subTitle={loadError.message}
extra={<Button onClick={() => void load()}>刷新重试</Button>}
/>
) : info ? (
<Space direction="vertical" size={14} className="full-width">
<div className="collect-link-heading">
<div>
<Title level={4}>{pageConfig?.title || '订单服务'}</Title>
<Text type="secondary">订单号:{info.platformOrderId}</Text>
{pageConfig?.description ? (
<div style={{ marginTop: 6 }}>
<Text type="secondary">{pageConfig.description}</Text>
</div>
) : null}
</div>
<Button
icon={<ReloadOutlined />}
size="small"
onClick={() => void load({ silent: true })}
>
刷新状态
</Button>
</div>
{submittedNames.length > 0 ? (
<Alert
type="success"
showIcon
message={lastSuccessText || '资料已提交'}
description={`${submittedNames.join('、')};页面会每 15 秒自动更新订单状态。`}
/>
) : null}
{pendingItems.length > 1 ? (
<Alert
type="info"
showIcon
message={`当前订单有 ${pendingItems.length} 个商品需要补充资料`}
description="请逐个商品提交;每个商品资料仅可提交一次。"
/>
) : pendingItems.length === 1 ? (
<Alert
type="warning"
showIcon
message="该商品资料仅可提交一次"
description="提交成功后不能再修改;如需修改请联系客服。"
/>
) : null}
{info.items.map((item) =>
item.canSubmit ? (
<OrderServiceForm
key={item.workOrderId}
item={item}
code={orderNo}
submitting={submittingId === item.workOrderId}
disabled={submittingId !== 0}
screenshots={screenshotsByOrder[item.workOrderId] ?? item.screenshots ?? []}
onScreenshotsChange={(files) => {
setScreenshotsByOrder((current) => ({
...current,
[item.workOrderId]: files,
}))
}}
onSubmit={(values) => void handleSubmit(item, values)}
/>
) : (
<OrderServiceStatusCard key={item.workOrderId} item={item} />
),
)}
<Text type="secondary" className="collect-link-footnote">
{pageConfig?.postSubmitText || '资料提交后如需修改,请联系客服。'}
</Text>
</Space>
) : null}
</Card>
</main>
)
}
function OrderServiceStatusCard({ item }: { item: OrderServiceItem }) {
const currentStep = STATUS_STEP_INDEX[item.state] ?? 0
const times = [
item.submittedAt ? { label: '资料提交时间', value: item.submittedAt } : null,
item.state === 'pending_acceptance' && item.acceptanceSubmittedAt
? { label: '凭证提交时间', value: item.acceptanceSubmittedAt }
: null,
item.state === 'accepted' && item.acceptedAt
? { label: '完成时间', value: item.acceptedAt }
: null,
].filter((row): row is { label: string; value: string } => Boolean(row?.value))
const acceptanceImageUrls = item.acceptanceImageUrls || []
const showSteps = item.state !== 'cancelled'
return (
<Card size="small" className="collect-link-status-card">
<Space direction="vertical" size={12} className="full-width">
<div className="collect-link-status-head">
<strong>{item.productName || item.categoryName || '订单商品'}</strong>
<Tag color={STATUS_TAG_COLORS[item.state] || 'default'}>{item.stateLabel}</Tag>
</div>
{showSteps ? (
<Steps
className="collect-link-status-steps"
current={currentStep}
items={STATUS_STEP_ITEMS}
responsive
size="small"
status={
item.state === 'accepted' ? 'finish' : item.state === 'problem' ? 'error' : 'process'
}
/>
) : null}
<div className={`collect-link-status-message is-${item.state}`}>{item.message}</div>
{times.length > 0 ? (
<div className="collect-link-status-times">
{times.map((row) => (
<span key={row.label}>
{row.label}:{formatOrderServiceTime(row.value)}
</span>
))}
</div>
) : null}
{acceptanceImageUrls.length > 0 ? (
<div className="collect-link-acceptance">
<div>
<Text strong>交付验收图片</Text>
<Text type="secondary">点击图片可查看大图</Text>
</div>
<Image.PreviewGroup>
<div className="collect-link-acceptance-images">
{acceptanceImageUrls.map((url, index) => (
<Image key={url} alt={`验收图片 ${index + 1}`} src={url} width={92} height={92} />
))}
</div>
</Image.PreviewGroup>
</div>
) : null}
</Space>
</Card>
)
}
function OrderServiceForm({
item,
code,
submitting,
disabled,
screenshots,
onScreenshotsChange,
onSubmit,
}: {
item: OrderServiceItem
code: string
submitting: boolean
disabled: boolean
screenshots: UploadedFile[]
onScreenshotsChange: (files: UploadedFile[]) => void
onSubmit: (values: Record<string, unknown>) => void
}) {
const rule = item.screenshotsRule || {
enabled: false,
required: false,
minCount: 0,
maxCount: 5,
label: '截图',
helpText: '',
}
return (
<Card size="small" className="collect-link-order-card">
<Space direction="vertical" size={12} className="full-width">
<div className="collect-link-order-head">
<div className="collect-link-order-name">
<span>{item.productName || item.categoryName || '订单商品'}</span>
</div>
<div className="collect-link-order-no">
<span>分类</span>
<strong>{item.categoryName || '-'}</strong>
</div>
</div>
{item.title ? <Title level={5}>{item.title}</Title> : null}
{item.description ? <Text type="secondary">{item.description}</Text> : null}
{item.guideImage && 'url' in item.guideImage && item.guideImage.url ? (
<img
alt="填写引导图"
src={String(item.guideImage.url)}
style={{ width: '100%', borderRadius: 8 }}
/>
) : null}
{item.fields && item.fields.length > 0 ? (
<Form layout="vertical" initialValues={item.values || {}} onFinish={onSubmit}>
{item.fields.map((field) => (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={6}>
<span>{field.label}</span>
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
</Space>
}
extra={field.helpText}
rules={[
...(field.required
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
: []),
...(field.validation === 'digits'
? [
{
validator: (_rule: unknown, value: unknown) => {
const text = String(value ?? '').trim()
if (!text || /^\d+$/.test(text)) return Promise.resolve()
return Promise.reject(new Error(`${field.label}只能填写数字`))
},
},
]
: []),
]}
>
{renderFieldInput(field)}
</Form.Item>
))}
{rule.enabled ? (
<Form.Item
label={
<Space size={6}>
<span>{rule.label}</span>
<Tag color={rule.required ? 'orange' : 'default'}>
{rule.required ? '必填' : '选填'}
</Tag>
</Space>
}
extra={rule.helpText}
>
<ImageUpload
scene="collect-material"
scope="collect"
maxCount={rule.maxCount}
showPasteHint
captureGlobalPaste
value={screenshots}
onChange={onScreenshotsChange}
uploader={async (file, scene) => {
const response = await uploadOrderServiceFile(
file,
scene,
code,
item.workOrderId,
)
return response.data.file
}}
/>
</Form.Item>
) : null}
<div className="collect-link-actions">
<Button
block
htmlType="submit"
loading={submitting}
disabled={disabled}
type="primary"
>
{item.submitText || '提交资料'}
</Button>
</div>
</Form>
) : (
<Alert type="warning" showIcon message="该商品尚未配置资料收集字段,请联系客服。" />
)}
</Space>
</Card>
)
}
function renderFieldInput(field: NonNullable<OrderServiceItem['fields']>[number]) {
const placeholder = field.placeholder || `请输入${field.label}`
if (field.validation === 'digits') {
return (
<Input
autoComplete="off"
inputMode="numeric"
maxLength={50}
pattern="[0-9]*"
placeholder={placeholder}
/>
)
}
if (field.type === 'textarea') {
return <Input.TextArea autoComplete="off" placeholder={placeholder} rows={3} />
}
if (field.type === 'select') {
return (
<Select
allowClear
placeholder={placeholder}
options={(field.options || []).map((option) => ({ label: option, value: option }))}
/>
)
}
if (field.type === 'radio') {
return (
<Radio.Group
options={(field.options || []).map((option) => ({ label: option, value: option }))}
/>
)
}
if (field.type === 'number') {
return <InputNumber style={{ width: '100%' }} placeholder={placeholder} />
}
if (field.type === 'date') {
return <DatePicker style={{ width: '100%' }} placeholder={placeholder} />
}
return <Input autoComplete="off" placeholder={placeholder} />
}
function MaterialConfirmationSummary({
item,
platformOrderId,
values,
screenshots,
notice,
}: {
item: OrderServiceItem
platformOrderId: string
values: Record<string, string>
screenshots: UploadedFile[]
notice: string
}) {
const screenshotRule = item.screenshotsRule
return (
<div className="collect-link-confirm">
<div className="collect-link-confirm-meta">
<span>{item.productName || item.categoryName || '订单商品'}</span>
<span>订单号:{platformOrderId}</span>
</div>
{item.fields && item.fields.length > 0 ? (
<div className="collect-link-confirm-fields">
{item.fields.map((field) => {
const value = String(values[field.key] || '').trim()
return (
<div className="collect-link-confirm-field" key={field.key}>
<span>{field.label}</span>
<strong className={value ? '' : 'is-empty'}>{value || '未填写'}</strong>
</div>
)
})}
</div>
) : null}
{screenshotRule?.enabled ? (
<div className="collect-link-confirm-screenshots">
<div>
<span>{screenshotRule.label || '截图'}</span>
<strong>{screenshots.length} 张</strong>
</div>
{screenshots.length > 0 ? (
<div className="collect-link-confirm-images">
{screenshots.map((file) => (
<img
key={file.objectKey}
alt={file.filename || '已上传截图'}
src={file.thumbnailUrl || file.url}
/>
))}
</div>
) : null}
</div>
) : null}
<div className="collect-link-confirm-content">{notice}</div>
</div>
)
}
function normalizeFormFieldValues(item: OrderServiceItem, values: Record<string, unknown>) {
const fields = new Map((item.fields || []).map((field) => [field.key, field]))
return Object.fromEntries(
Object.entries(values).map(([key, value]) => {
const field = fields.get(key)
if (field?.type === 'date' && value && dayjs.isDayjs(value)) {
return [key, (value as Dayjs).format('YYYY-MM-DD')]
}
return [key, String(value ?? '').trim()]
}),
)
}
function formatOrderServiceTime(value: string) {
const parsed = dayjs(value)
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : value
}