Files
YelayMaths/yelaymaths_admin/src/app/(admin)/orders/page.tsx
T
2026-09-30 15:08:16 +08:00

274 lines
11 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.
'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>
);
}