支付通路
This commit is contained in:
1 parent
c7f43e30a9
commit
85b0e70907
26 files changed
+1324
-74
No files matched your search
@@ -3,6 +3,7 @@
|
||||
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 {
|
||||
@@ -11,6 +12,12 @@ interface OrderRow {
|
||||
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;
|
||||
@@ -22,6 +29,16 @@ interface OrderRow {
|
||||
|
||||
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);
|
||||
@@ -30,7 +47,9 @@ export default function OrdersPage() {
|
||||
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('');
|
||||
@@ -53,9 +72,9 @@ export default function OrdersPage() {
|
||||
setError('');
|
||||
setNotice('');
|
||||
api
|
||||
.refundOrder(refundTarget.id, reason)
|
||||
.refundOrder(refundTarget.id, reason, Number(reasonCode))
|
||||
.then(() => {
|
||||
setNotice('退款请求已提交。iOS 端需用户自行向 App Store 申请,开发者无法发起。');
|
||||
setNotice('退款请求已提交。退款完成后订单会自动变为「已退款」,并收回课程权益。');
|
||||
setRefundTarget(null);
|
||||
load(page, status);
|
||||
})
|
||||
@@ -64,6 +83,43 @@ export default function OrdersPage() {
|
||||
|
||||
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>
|
||||
@@ -103,6 +159,7 @@ export default function OrdersPage() {
|
||||
<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>
|
||||
@@ -112,17 +169,28 @@ export default function OrdersPage() {
|
||||
{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">{o.nickname || o.openid.slice(0, 8)}</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">{ORDER_STATUS_TEXT[o.status] ?? o.status}</td>
|
||||
<td className="px-2 py-2 text-gray-500">{String(o.created_at).slice(0, 16).replace('T', ' ')}</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 && (
|
||||
<Button variant="danger" onClick={() => setRefundTarget(o)}>
|
||||
退款
|
||||
</Button>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
@@ -151,9 +219,17 @@ export default function OrdersPage() {
|
||||
<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.out_trade_no},金额 ¥{(refundTarget.amount_fen / 100).toFixed(2)},
|
||||
{refundTarget.terminal || '未知终端'}
|
||||
</p>
|
||||
<Input value={reason} onChange={setReason} placeholder="退款原因" />
|
||||
<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)}>
|
||||
取消
|
||||
@@ -165,7 +241,33 @@ export default function OrdersPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user