89fd333702
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload. Co-authored-by: Cursor <cursoragent@cursor.com>
236 lines
8.2 KiB
TypeScript
236 lines
8.2 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import {
|
|
AFTER_SALE_TICKET_TYPES,
|
|
TICKET_TYPE_LABELS,
|
|
type AfterSaleTicketType,
|
|
} from '@dukang/shared-types';
|
|
import SubPageHeader from '../components/SubPageHeader';
|
|
import { request } from '../lib/api';
|
|
import { uploadFileToOss } from '../lib/upload';
|
|
import { usePageView } from '../lib/usePageView';
|
|
import { track } from '../lib/analytics';
|
|
|
|
type OrderRow = {
|
|
id: string;
|
|
orderNo: string;
|
|
status: string;
|
|
payAmount: number | string;
|
|
productName?: string;
|
|
createdAt?: string;
|
|
};
|
|
|
|
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
|
|
|
|
export default function AfterSalePage() {
|
|
usePageView('after_sale_apply');
|
|
const navigate = useNavigate();
|
|
const [params] = useSearchParams();
|
|
const presetOrderId = params.get('orderId') || '';
|
|
const presetType = (params.get('type') as AfterSaleTicketType | null) || null;
|
|
|
|
const [step, setStep] = useState(0);
|
|
const [ticketType, setTicketType] = useState<AfterSaleTicketType | null>(
|
|
presetType && AFTER_SALE_TICKET_TYPES.includes(presetType) ? presetType : null,
|
|
);
|
|
const [orders, setOrders] = useState<OrderRow[]>([]);
|
|
const [orderId, setOrderId] = useState(presetOrderId);
|
|
const [remark, setRemark] = useState('');
|
|
const [evidenceUrls, setEvidenceUrls] = useState<string[]>([]);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [ticketNo, setTicketNo] = useState('');
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=paid&pageSize=50'),
|
|
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50'),
|
|
])
|
|
.then(([paid, completed]) => {
|
|
const map = new Map<string, OrderRow>();
|
|
[...(paid.list ?? []), ...(completed.list ?? [])].forEach((o) => map.set(o.id, o));
|
|
setOrders([...map.values()]);
|
|
})
|
|
.catch(() => setOrders([]));
|
|
}, []);
|
|
|
|
const selectedOrder = useMemo(() => orders.find((o) => o.id === orderId), [orders, orderId]);
|
|
|
|
async function onPickFiles(files: FileList | null) {
|
|
if (!files?.length) return;
|
|
setUploading(true);
|
|
try {
|
|
const uploaded: string[] = [];
|
|
for (const file of Array.from(files).slice(0, 6 - evidenceUrls.length)) {
|
|
const res = await uploadFileToOss(file, { bizType: 'after-sale' });
|
|
uploaded.push(res.url);
|
|
}
|
|
setEvidenceUrls((prev) => [...prev, ...uploaded].slice(0, 6));
|
|
} catch (e) {
|
|
window.alert(e instanceof Error ? e.message : '上传失败');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
async function submit() {
|
|
if (!ticketType || !orderId) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const ticket = await request<{ ticketNo: string }>('USER_H5', `/trade/orders/${orderId}/after-sale-tickets`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
ticketType,
|
|
remark: remark.trim() || undefined,
|
|
evidenceUrls,
|
|
}),
|
|
});
|
|
setTicketNo(ticket.ticketNo);
|
|
setStep(3);
|
|
} catch (e) {
|
|
window.alert(e instanceof Error ? e.message : '提交失败');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
function nextFromType() {
|
|
if (!ticketType) {
|
|
window.alert('请选择售后类型');
|
|
return;
|
|
}
|
|
setStep(1);
|
|
}
|
|
|
|
function nextFromOrder() {
|
|
if (!orderId) {
|
|
window.alert('请选择订单');
|
|
return;
|
|
}
|
|
setStep(2);
|
|
}
|
|
|
|
return (
|
|
<div className="after-sale-page">
|
|
<SubPageHeader title="申请售后" onBack={() => navigate(-1)} />
|
|
|
|
<div className="after-sale-steps">
|
|
{STEPS.map((label, i) => (
|
|
<span key={label} className={`after-sale-step${i === step ? ' is-active' : i < step ? ' is-done' : ''}`}>
|
|
{label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<main className="after-sale-body">
|
|
{step === 0 && (
|
|
<div className="after-sale-type-list">
|
|
{AFTER_SALE_TICKET_TYPES.map((t) => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
className={`after-sale-type-item${ticketType === t ? ' is-selected' : ''}`}
|
|
onClick={() => setTicketType(t)}
|
|
>
|
|
<span>{TICKET_TYPE_LABELS[t]}</span>
|
|
<span className="material-symbols-outlined">chevron_right</span>
|
|
</button>
|
|
))}
|
|
<button type="button" className="after-sale-primary" onClick={nextFromType}>
|
|
下一步
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{step === 1 && (
|
|
<div className="after-sale-order-list">
|
|
{orders.length === 0 ? (
|
|
<p className="after-sale-empty">暂无可售后订单</p>
|
|
) : (
|
|
orders.map((o) => (
|
|
<button
|
|
key={o.id}
|
|
type="button"
|
|
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
|
onClick={() => setOrderId(o.id)}
|
|
>
|
|
<p className="after-sale-order-no">{o.orderNo}</p>
|
|
<p className="after-sale-order-meta">
|
|
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
|
</p>
|
|
</button>
|
|
))
|
|
)}
|
|
<div className="after-sale-actions">
|
|
<button type="button" className="after-sale-secondary" onClick={() => setStep(0)}>
|
|
上一步
|
|
</button>
|
|
<button type="button" className="after-sale-primary" onClick={nextFromOrder}>
|
|
下一步
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<div className="after-sale-form">
|
|
<p className="after-sale-summary">
|
|
{ticketType ? TICKET_TYPE_LABELS[ticketType] : ''} · {selectedOrder?.orderNo ?? orderId}
|
|
</p>
|
|
<label className="after-sale-label">问题描述</label>
|
|
<textarea
|
|
className="after-sale-textarea"
|
|
rows={4}
|
|
placeholder="请描述问题(选填)"
|
|
value={remark}
|
|
onChange={(e) => setRemark(e.target.value)}
|
|
/>
|
|
<label className="after-sale-label">凭证图片(破损类建议上传)</label>
|
|
<div className="after-sale-evidence">
|
|
{evidenceUrls.map((url) => (
|
|
<img key={url} src={url} alt="" className="after-sale-evidence-img" />
|
|
))}
|
|
{evidenceUrls.length < 6 && (
|
|
<label className="after-sale-evidence-add">
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
hidden
|
|
disabled={uploading}
|
|
onChange={(e) => void onPickFiles(e.target.files)}
|
|
/>
|
|
{uploading ? '上传中' : '+'}
|
|
</label>
|
|
)}
|
|
</div>
|
|
<div className="after-sale-actions">
|
|
<button type="button" className="after-sale-secondary" onClick={() => setStep(1)}>
|
|
上一步
|
|
</button>
|
|
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
|
{submitting ? '提交中…' : '提交工单'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<div className="after-sale-success">
|
|
<span className="material-symbols-outlined after-sale-success-icon">check_circle</span>
|
|
<p className="after-sale-success-title">售后已提交</p>
|
|
<p className="after-sale-success-no">工单号 {ticketNo}</p>
|
|
<p className="after-sale-success-hint">总部将尽快审核,请留意处理进度</p>
|
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale/list')}>
|
|
查看我的售后
|
|
</button>
|
|
<button type="button" className="after-sale-secondary" onClick={() => navigate('/orders')}>
|
|
返回订单
|
|
</button>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|