274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { api } from '@/lib/api';
|
||
import { ORDER_STATUS_TEXT } from '@/lib/resources';
|
||
import { formatDateTime } from '@/lib/datetime';
|
||
import { Button, Card, Empty, Input, Notice, Select } from '@/components/ui';
|
||
|
||
interface OrderRow {
|
||
id: number;
|
||
out_trade_no: string;
|
||
wx_order_id: string | null;
|
||
status: number;
|
||
amount_fen: number;
|
||
/** 下单设备:ios 走 Apple 支付,不能主动退款 */
|
||
terminal: string | null;
|
||
/** 管理员已同意退款的时间;非空表示 iOS 退款问询会放过 */
|
||
refund_consent_at: string | null;
|
||
/** 已向微信发起退款的时间;非空且未退款完成时显示「退款中」 */
|
||
refund_requested_at: string | null;
|
||
nickname: string | null;
|
||
openid: string;
|
||
course_title: string;
|
||
created_at: string;
|
||
paid_at: string | null;
|
||
delivered_at: string | null;
|
||
refunded_at: string | null;
|
||
}
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
/** 微信退款接口只认这几个枚举值,不能传自由文本 */
|
||
const REFUND_REASONS = [
|
||
{ value: 3, label: '3 用户主动退款' },
|
||
{ value: 0, label: '0 暂无描述' },
|
||
{ value: 1, label: '1 产品问题,影响使用或效果不佳' },
|
||
{ value: 2, label: '2 售后问题,无法满足需求' },
|
||
{ value: 4, label: '4 价格问题' },
|
||
{ value: 5, label: '5 其他原因' },
|
||
];
|
||
|
||
export default function OrdersPage() {
|
||
const [items, setItems] = useState<OrderRow[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [status, setStatus] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [notice, setNotice] = useState('');
|
||
const [refundTarget, setRefundTarget] = useState<OrderRow | null>(null);
|
||
const [consentTarget, setConsentTarget] = useState<OrderRow | null>(null);
|
||
const [reason, setReason] = useState('用户申请退款');
|
||
const [reasonCode, setReasonCode] = useState('3');
|
||
|
||
const load = useCallback((nextPage: number, nextStatus: string) => {
|
||
setError('');
|
||
api
|
||
.listOrders(nextPage, PAGE_SIZE, nextStatus === '' ? undefined : Number(nextStatus))
|
||
.then((res) => {
|
||
setItems(res.items as unknown as OrderRow[]);
|
||
setTotal(res.total);
|
||
setPage(res.page);
|
||
})
|
||
.catch((err: { message: string }) => setError(err.message));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
load(1, '');
|
||
}, [load]);
|
||
|
||
const submitRefund = () => {
|
||
if (!refundTarget) return;
|
||
setError('');
|
||
setNotice('');
|
||
api
|
||
.refundOrder(refundTarget.id, reason, Number(reasonCode))
|
||
.then(() => {
|
||
setNotice('退款请求已提交。退款完成后订单会自动变为「已退款」,并收回课程权益。');
|
||
setRefundTarget(null);
|
||
load(page, status);
|
||
})
|
||
.catch((err: { message: string }) => setError(err.message));
|
||
};
|
||
|
||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||
|
||
/** iOS 订单只能「允许」——打标记,钱的事由苹果处理,我们发起不了退款。 */
|
||
const isIos = (order: OrderRow) => order.terminal === 'ios';
|
||
|
||
/**
|
||
* 列表里显示的状态。
|
||
* 退款是异步的:微信受理后要等它推 xpay_refund_notify 才会变成「已退款」,
|
||
* 中间这段必须显示「退款中」,否则看起来像没退成功,管理员会再点一次。
|
||
*/
|
||
const statusOf = (order: OrderRow) => {
|
||
if (order.status === 3) return { text: '已退款', className: 'text-red-600' };
|
||
if (order.refund_requested_at) return { text: '退款中', className: 'text-amber-600' };
|
||
return {
|
||
text: ORDER_STATUS_TEXT[order.status] ?? String(order.status),
|
||
className: '',
|
||
};
|
||
};
|
||
|
||
/** 打 / 取消「允许退款」。只改标记,不发起任何退款动作。 */
|
||
const submitConsent = () => {
|
||
if (!consentTarget) return;
|
||
const order = consentTarget;
|
||
setError('');
|
||
setNotice('');
|
||
api
|
||
.setRefundConsent(order.id, !order.refund_consent_at)
|
||
.then(() => {
|
||
setNotice(
|
||
order.refund_consent_at
|
||
? '已取消「允许退款」。用户再申请时我们会拒绝。'
|
||
: '已允许退款。请让用户现在去 App Store 申请,我们会在问询环节放行。',
|
||
);
|
||
setConsentTarget(null);
|
||
load(page, status);
|
||
})
|
||
.catch((err: { message: string }) => setError(err.message));
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h1 className="text-lg font-semibold text-gray-900">订单</h1>
|
||
<Notice message={error} />
|
||
{notice && <Notice message={notice} kind="success" />}
|
||
|
||
<Card
|
||
title={`共 ${total} 笔订单`}
|
||
action={
|
||
<div className="flex gap-2">
|
||
<Select
|
||
value={status}
|
||
onChange={setStatus}
|
||
options={[
|
||
{ value: -1, label: '全部状态' },
|
||
{ value: 0, label: '待支付' },
|
||
{ value: 1, label: '已支付' },
|
||
{ value: 2, label: '已开通' },
|
||
{ value: 3, label: '已退款' },
|
||
{ value: 4, label: '已关闭' },
|
||
]}
|
||
/>
|
||
<Button variant="ghost" onClick={() => load(1, status === '-1' ? '' : status)}>
|
||
筛选
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
{items.length === 0 ? (
|
||
<Empty text="暂无订单" />
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||
<th className="px-2 py-2">订单号</th>
|
||
<th className="px-2 py-2">用户</th>
|
||
<th className="px-2 py-2">课程</th>
|
||
<th className="px-2 py-2">金额</th>
|
||
<th className="px-2 py-2">终端</th>
|
||
<th className="px-2 py-2">状态</th>
|
||
<th className="px-2 py-2">下单时间</th>
|
||
<th className="px-2 py-2 text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map((o) => (
|
||
<tr key={o.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||
<td className="px-2 py-2 font-mono text-xs">{o.out_trade_no}</td>
|
||
<td className="px-2 py-2">
|
||
<div>{o.nickname || <span className="text-gray-400">游客</span>}</div>
|
||
<div className="font-mono text-xs text-gray-400">{o.openid}</div>
|
||
</td>
|
||
<td className="px-2 py-2">{o.course_title}</td>
|
||
<td className="px-2 py-2">¥{(o.amount_fen / 100).toFixed(2)}</td>
|
||
<td className="px-2 py-2 text-gray-500">{o.terminal || '—'}</td>
|
||
<td className={`px-2 py-2 ${statusOf(o).className}`}>{statusOf(o).text}</td>
|
||
<td className="px-2 py-2 text-gray-500">{formatDateTime(o.created_at)}</td>
|
||
<td className="px-2 py-2 text-right">
|
||
{o.status === 2 &&
|
||
(o.refund_requested_at ? (
|
||
<span className="text-xs text-amber-600">退款中…</span>
|
||
) : isIos(o) ? (
|
||
<Button variant="ghost" onClick={() => setConsentTarget(o)}>
|
||
{o.refund_consent_at ? '取消允许退款' : '允许退款'}
|
||
</Button>
|
||
) : (
|
||
<Button variant="danger" onClick={() => setRefundTarget(o)}>
|
||
直接退款
|
||
</Button>
|
||
))}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||
<span>
|
||
第 {page} / {totalPages} 页
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<Button variant="ghost" disabled={page <= 1} onClick={() => load(page - 1, status)}>
|
||
上一页
|
||
</Button>
|
||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1, status)}>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{refundTarget && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||
<div className="w-full max-w-md rounded-lg bg-white p-5 shadow-xl">
|
||
<h3 className="mb-3 text-base font-semibold">发起退款</h3>
|
||
<p className="mb-3 text-sm text-gray-600">
|
||
订单 {refundTarget.out_trade_no},金额 ¥{(refundTarget.amount_fen / 100).toFixed(2)},
|
||
{refundTarget.terminal || '未知终端'}
|
||
</p>
|
||
<p className="mb-3 text-sm text-gray-600">
|
||
这会<span className="font-semibold text-red-600">真的把钱退回去</span>
|
||
,退款完成后订单变为「已退款」并收回课程权益。
|
||
</p>
|
||
<label className="mb-1 block text-xs text-gray-500">退款原因分类(微信要求选一类)</label>
|
||
<Select value={reasonCode} onChange={setReasonCode} options={REFUND_REASONS} />
|
||
<label className="mb-1 mt-3 block text-xs text-gray-500">备注(写给自己看,会随退款一起提交存档)</label>
|
||
<Input value={reason} onChange={setReason} placeholder="例如:用户联系客服申请" />
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<Button variant="ghost" onClick={() => setRefundTarget(null)}>
|
||
取消
|
||
</Button>
|
||
<Button variant="danger" onClick={submitRefund}>
|
||
确认退款
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{consentTarget && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||
<div className="w-full max-w-md rounded-lg bg-white p-5 shadow-xl">
|
||
<h3 className="mb-3 text-base font-semibold">
|
||
{consentTarget.refund_consent_at ? '取消允许退款' : '允许退款'}
|
||
</h3>
|
||
<p className="mb-3 text-sm text-gray-600">
|
||
订单 {consentTarget.out_trade_no},金额 ¥{(consentTarget.amount_fen / 100).toFixed(2)},
|
||
{consentTarget.terminal || '未知终端'}(Apple 支付)
|
||
</p>
|
||
<p className="mb-4 text-sm text-gray-600">
|
||
{consentTarget.refund_consent_at
|
||
? '取消后,用户再发起退款申请,我们会在苹果问询环节拒绝。'
|
||
: '不发起退款、也不动钱,只打一个标记。请先和用户沟通好,再让他在 App Store 申请退款;那时我们会放行。顺序反了无效。'}
|
||
</p>
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<Button variant="ghost" onClick={() => setConsentTarget(null)}>
|
||
取消
|
||
</Button>
|
||
<Button variant="danger" onClick={submitConsent}>
|
||
{consentTarget.refund_consent_at ? '确认取消' : '确认允许'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|