支付通路
This commit is contained in:
1 parent
c7f43e30a9
commit
85b0e70907
26 files changed
+1324
-74
No files matched your search
@@ -14,6 +14,8 @@ const FIXED_NAV = [
|
||||
{ href: '/', label: '数据看板' },
|
||||
{ href: '/users', label: '用户' },
|
||||
{ href: '/orders', label: '订单' },
|
||||
{ href: '/refund-requests', label: '退款申请' },
|
||||
{ href: '/notify-logs', label: '通知日志' },
|
||||
{ href: '/settings', label: '系统配置' },
|
||||
];
|
||||
|
||||
@@ -263,4 +265,3 @@ function NavItem({ href, label, active }: { href: string; label: string; active:
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/datetime';
|
||||
import { Button, Card, Empty, Notice } from '@/components/ui';
|
||||
|
||||
interface NotifyLogRow {
|
||||
id: number;
|
||||
event: string | null;
|
||||
event_label: string;
|
||||
/** 0 收到但没处理 / 1 处理成功 / 2 处理失败 */
|
||||
processed: number;
|
||||
err_msg: string | null;
|
||||
order_no: string | null;
|
||||
raw_body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const RESULT: Record<number, { text: string; className: string }> = {
|
||||
0: { text: '已忽略', className: 'text-gray-500' },
|
||||
1: { text: '成功', className: 'text-green-600' },
|
||||
2: { text: '失败', className: 'text-red-600' },
|
||||
};
|
||||
|
||||
export default function NotifyLogsPage() {
|
||||
const [items, setItems] = useState<NotifyLogRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback((nextPage: number) => {
|
||||
setError('');
|
||||
api
|
||||
.listNotifyLogs(nextPage, PAGE_SIZE)
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as NotifyLogRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(1);
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">通知日志</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
微信服务器发给我们的每一条推送都在这里:发货、退款问询、退款成功、投诉、风控等。
|
||||
用来确认「这条通知到底有没有来、我们是怎么处理的」。没收到推送不代表订单有问题——
|
||||
平台未必每笔都通知(比如 iOS 退款,苹果可能自己判定不发问询)。
|
||||
</p>
|
||||
<Notice message={error} />
|
||||
|
||||
<Card>
|
||||
<div className="mb-4 text-sm text-gray-500">共 {total} 条通知</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => {
|
||||
const result = RESULT[r.processed] ?? { text: String(r.processed), className: '' };
|
||||
return (
|
||||
<tr key={r.id} className="border-b border-gray-100 align-top hover:bg-gray-50">
|
||||
<td className="px-2 py-2 text-gray-500">{formatDateTime(r.created_at)}</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.event_label}
|
||||
<div className="font-mono text-xs text-gray-400">{r.event || '(无 Event 字段)'}</div>
|
||||
</td>
|
||||
<td className={`px-2 py-2 ${result.className}`}>{result.text}</td>
|
||||
<td className="px-2 py-2 font-mono text-xs">{r.order_no || '—'}</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.err_msg && <div className="text-gray-600">{r.err_msg}</div>}
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-xs text-blue-600">查看原始报文</summary>
|
||||
<pre className="mt-1 max-w-xl overflow-x-auto whitespace-pre-wrap break-all rounded bg-gray-50 p-2 text-xs text-gray-600">
|
||||
{r.raw_body}
|
||||
</pre>
|
||||
</details>
|
||||
</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)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
'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, Notice } from '@/components/ui';
|
||||
|
||||
interface RefundRequestRow {
|
||||
id: number;
|
||||
pay_order_id: string;
|
||||
out_trade_no: string | null;
|
||||
nickname: string | null;
|
||||
openid: string | null;
|
||||
course_title: string | null;
|
||||
amount_fen: number | null;
|
||||
terminal: string | null;
|
||||
order_status: number | null;
|
||||
reason: string | null;
|
||||
provide_status: string | null;
|
||||
our_result: number;
|
||||
our_evidence: string | null;
|
||||
query_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** 我们答复时看到的发货状态 */
|
||||
const PROVIDE_STATUS_TEXT: Record<string, string> = {
|
||||
'0': '未发货',
|
||||
'1': '已发货',
|
||||
'2': '发货中',
|
||||
};
|
||||
|
||||
export default function RefundRequestsPage() {
|
||||
const [items, setItems] = useState<RefundRequestRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback((nextPage: number) => {
|
||||
setError('');
|
||||
api
|
||||
.listRefundRequests(nextPage, PAGE_SIZE)
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as RefundRequestRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(1);
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">退款申请</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Apple 用户去 App Store 申请退款后,平台会向我们发退款问询。问询要求 3 秒内答复,所以这里是「我们已经怎么答的」,
|
||||
不是待办。Apple 最终退了钱的话,对应订单会变成「已退款」,权益同时被收回。
|
||||
</p>
|
||||
<Notice message={error} />
|
||||
|
||||
<Card>
|
||||
<div className="mb-4 text-sm text-gray-500">共 {total} 条申请</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="border-b border-gray-100 align-top hover:bg-gray-50">
|
||||
<td className="px-2 py-2 text-gray-500">
|
||||
{formatDateTime(r.created_at)}
|
||||
{r.query_count > 1 && <div className="text-xs text-gray-400">问询 ×{r.query_count}</div>}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div>{r.nickname || <span className="text-gray-400">游客</span>}</div>
|
||||
{r.openid && <div className="font-mono text-xs text-gray-400">{r.openid}</div>}
|
||||
</td>
|
||||
<td className="px-2 py-2 font-mono text-xs">
|
||||
{r.out_trade_no || <span className="text-gray-400">未匹配到订单</span>}
|
||||
<div className="text-gray-400">{r.pay_order_id}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.course_title || '—'}
|
||||
{r.amount_fen !== null && (
|
||||
<div className="text-gray-500">¥{(r.amount_fen / 100).toFixed(2)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-gray-600">{r.reason || '—'}</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={r.our_result === 1 ? 'text-red-600' : 'text-green-600'}>
|
||||
{r.our_result === 1 ? '建议拒绝' : '建议退款'}
|
||||
</span>
|
||||
<div className="text-xs text-gray-400">
|
||||
平台发货状态:{PROVIDE_STATUS_TEXT[r.provide_status ?? ''] ?? '未知'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">{r.our_evidence}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.order_status === null ? (
|
||||
<span className="text-gray-400">—</span>
|
||||
) : (
|
||||
<span className={r.order_status === 3 ? 'text-red-600' : 'text-gray-600'}>
|
||||
{ORDER_STATUS_TEXT[r.order_status] ?? r.order_status}
|
||||
</span>
|
||||
)}
|
||||
</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)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button, Card, Empty, Input, Modal, Notice, Select } from '@/components/ui';
|
||||
import { formatDate } from '@/lib/datetime';
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
@@ -157,6 +158,7 @@ export default function UsersPage() {
|
||||
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||
<th className="px-2 py-2">ID</th>
|
||||
<th className="px-2 py-2">昵称</th>
|
||||
<th className="px-2 py-2">openid</th>
|
||||
<th className="px-2 py-2">已购课程</th>
|
||||
<th className="px-2 py-2">状态</th>
|
||||
<th className="px-2 py-2">最后登录</th>
|
||||
@@ -168,12 +170,13 @@ export default function UsersPage() {
|
||||
<tr key={u.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-2 py-2 text-gray-400">{u.id}</td>
|
||||
<td className="px-2 py-2">{u.nickname || <span className="text-gray-400">游客</span>}</td>
|
||||
<td className="px-2 py-2 font-mono text-xs text-gray-500">{u.openid}</td>
|
||||
<td className="px-2 py-2">{u.entitlement_count}</td>
|
||||
<td className="px-2 py-2">
|
||||
{u.status === 1 ? <span className="text-green-600">正常</span> : <span className="text-red-600">封禁</span>}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-gray-500">
|
||||
{u.last_login_at ? String(u.last_login_at).slice(0, 10) : '—'}
|
||||
{formatDate(u.last_login_at)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button variant="ghost" onClick={() => openEdit(u)}>
|
||||
@@ -286,4 +289,3 @@ export default function UsersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,8 +145,23 @@ export const api = {
|
||||
listOrders: (page: number, pageSize: number, status?: number) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/orders${qs({ page, pageSize, status })}`),
|
||||
|
||||
refundOrder: (id: number, reason: string) =>
|
||||
request<unknown>(`/admin/v1/orders/${id}/refund`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
listRefundRequests: (page: number, pageSize: number) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/refund-requests${qs({ page, pageSize })}`),
|
||||
|
||||
listNotifyLogs: (page: number, pageSize: number) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/notify-logs${qs({ page, pageSize })}`),
|
||||
|
||||
refundOrder: (id: number, reason: string, reasonCode: number) =>
|
||||
request<unknown>(`/admin/v1/orders/${id}/refund`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason, reasonCode: String(reasonCode) }),
|
||||
}),
|
||||
|
||||
setRefundConsent: (id: number, consent: boolean) =>
|
||||
request<unknown>(`/admin/v1/orders/${id}/refund-consent`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ consent }),
|
||||
}),
|
||||
|
||||
listEntitlements: (userId: number) => request<Array<Record<string, unknown>>>(`/admin/v1/entitlements${qs({ userId })}`),
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 后台统一按北京时间展示。
|
||||
*
|
||||
* 后端返回的是 ISO 字符串(UTC,带 Z),直接切片会显示成 UTC——
|
||||
* 中午 13:23 会显示成 05:23。所有时间展示都走这里。不要再用 slice。
|
||||
*/
|
||||
const TIME_ZONE = 'Asia/Shanghai';
|
||||
|
||||
function partsOf(value: unknown) {
|
||||
const date = new Date(String(value));
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
const pick = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
|
||||
return { y: pick('year'), m: pick('month'), d: pick('day'), h: pick('hour'), mi: pick('minute') };
|
||||
}
|
||||
|
||||
/** ISO → 2026-09-30 13:23 */
|
||||
export function formatDateTime(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const p = partsOf(value);
|
||||
return p ? `${p.y}-${p.m}-${p.d} ${p.h}:${p.mi}` : String(value).slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
|
||||
/** ISO → 2026-09-30 */
|
||||
export function formatDate(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const p = partsOf(value);
|
||||
return p ? `${p.y}-${p.m}-${p.d}` : String(value).slice(0, 10);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
Reference in new issue
Block a user