Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8129cb93e |
@@ -6,6 +6,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Checkbox,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Select,
|
Select,
|
||||||
@@ -17,10 +18,9 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
|
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
|
||||||
|
|
||||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
|
||||||
|
|
||||||
const SCENE_TAG: Record<string, { color: string; label: string }> = {
|
const SCENE_TAG: Record<string, { color: string; label: string }> = {
|
||||||
reward_video: { color: 'blue', label: '看视频' },
|
reward_video: { color: 'blue', label: '看视频' },
|
||||||
feed: { color: 'purple', label: '信息流' },
|
feed: { color: 'purple', label: '信息流' },
|
||||||
@@ -48,6 +48,8 @@ export default function AdAuditPage() {
|
|||||||
const [scene, setScene] = useState<string | undefined>();
|
const [scene, setScene] = useState<string | undefined>();
|
||||||
const [limit, setLimit] = useState<number>(100);
|
const [limit, setLimit] = useState<number>(100);
|
||||||
const [data, setData] = useState<AdCoinAudit | null>(null);
|
const [data, setData] = useState<AdCoinAudit | null>(null);
|
||||||
|
const [queriedLimit, setQueriedLimit] = useState<number>(100); // 本次结果对应的 limit,截断提示里展示
|
||||||
|
const [onlyMismatch, setOnlyMismatch] = useState(false); // 只看不一致(✗)行
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -59,15 +61,17 @@ export default function AdAuditPage() {
|
|||||||
user_id: userId ?? undefined,
|
user_id: userId ?? undefined,
|
||||||
scene: scene ?? undefined,
|
scene: scene ?? undefined,
|
||||||
limit,
|
limit,
|
||||||
|
only_mismatch: onlyMismatch || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setData(res.data);
|
setData(res.data);
|
||||||
|
setQueriedLimit(limit);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(errMsg(e));
|
message.error(errMsg(e));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [date, userId, scene, limit]);
|
}, [date, userId, scene, limit, onlyMismatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -85,7 +89,7 @@ export default function AdAuditPage() {
|
|||||||
return <Tag color={t.color}>{t.label}</Tag>;
|
return <Tag color={t.color}>{t.label}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '时间', dataIndex: 'created_at', render: dt, width: 175 },
|
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 175 },
|
||||||
{ title: '用户', dataIndex: 'user_id', width: 70 },
|
{ title: '用户', dataIndex: 'user_id', width: 70 },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -186,6 +190,9 @@ export default function AdAuditPage() {
|
|||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n} 条` }))}
|
options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox checked={onlyMismatch} onChange={(e) => setOnlyMismatch(e.target.checked)}>
|
||||||
|
只看不一致(✗)
|
||||||
|
</Checkbox>
|
||||||
<Button type="primary" onClick={load} loading={loading}>
|
<Button type="primary" onClick={load} loading={loading}>
|
||||||
查询
|
查询
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,17 +200,30 @@ export default function AdAuditPage() {
|
|||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
{data.mismatch_count > 0 ? (
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
<Alert
|
{data.mismatch_count > 0 ? (
|
||||||
type="error"
|
<Alert
|
||||||
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
type="error"
|
||||||
/>
|
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
||||||
) : (
|
/>
|
||||||
<Alert
|
) : (
|
||||||
type="success"
|
<Alert type="success" message={`共 ${data.total} 条,全部按公式发放(无不一致)`} />
|
||||||
message={`共 ${data.total} 条,全部按公式发放(无不一致)`}
|
)}
|
||||||
/>
|
{onlyMismatch && (
|
||||||
)}
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={`当前只展示不一致(✗)行;上方统计仍为全量(共 ${data.total} 条)。`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data.truncated && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={`展示明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/场景/日期缩小范围,或调大「条数」。统计数字不受影响。`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,94 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { Card, Col, Descriptions, Row, Spin, Statistic, Table, Tabs, Tag } from 'antd';
|
import {
|
||||||
import { api } from '@/lib/api';
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Descriptions,
|
||||||
|
Modal,
|
||||||
|
Result,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { canDo } from '@/lib/auth';
|
||||||
|
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { useCursorList } from '@/lib/useCursorList';
|
||||||
import type { CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||||
|
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
|
||||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
|
||||||
|
|
||||||
export default function UserDetailPage() {
|
export default function UserDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const uid = Number(params.id);
|
const uid = Number(params.id);
|
||||||
const [data, setData] = useState<UserOverview | null>(null);
|
const [data, setData] = useState<UserOverview | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [coinOpen, setCoinOpen] = useState(false);
|
||||||
|
const [cashOpen, setCashOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback(() => {
|
||||||
api.get<UserOverview>(`/admin/api/users/${uid}`).then((r) => setData(r.data));
|
setErr(null);
|
||||||
|
api
|
||||||
|
.get<UserOverview>(`/admin/api/users/${uid}`)
|
||||||
|
.then((r) => setData(r.data))
|
||||||
|
.catch((e) => setErr(errMsg(e, '加载用户详情失败')));
|
||||||
}, [uid]);
|
}, [uid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
const coins = useCursorList<CoinTxn>('/admin/api/wallet/coin-transactions', { user_id: uid });
|
const coins = useCursorList<CoinTxn>('/admin/api/wallet/coin-transactions', { user_id: uid });
|
||||||
|
const cash = useCursorList<CashTxn>('/admin/api/wallet/cash-transactions', { user_id: uid });
|
||||||
const withdraws = useCursorList<WithdrawOrder>('/admin/api/withdraws', { user_id: uid });
|
const withdraws = useCursorList<WithdrawOrder>('/admin/api/withdraws', { user_id: uid });
|
||||||
|
|
||||||
|
const canCoins = canDo(['finance']);
|
||||||
|
const canStatus = canDo(['operator']);
|
||||||
|
|
||||||
|
const toggleStatus = () => {
|
||||||
|
if (!data) return;
|
||||||
|
const next = data.user.status === 'active' ? 'disabled' : 'active';
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`,
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/api/users/${uid}/status`, { status: next });
|
||||||
|
message.success('已更新状态');
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="加载失败"
|
||||||
|
subTitle={err}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={loadData}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!data) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
if (!data) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||||
|
|
||||||
const coinCols: ColumnsType<CoinTxn> = [
|
const coinCols: ColumnsType<CoinTxn> = [
|
||||||
{ title: '时间', dataIndex: 'created_at', render: dt },
|
// 金币流水为北京 wall-clock 口径,原样显示
|
||||||
|
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) },
|
||||||
{
|
{
|
||||||
title: '变动',
|
title: '变动',
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
@@ -42,17 +104,58 @@ export default function UserDetailPage() {
|
|||||||
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const cashCols: ColumnsType<CashTxn> = [
|
||||||
|
// 现金流水为北京 wall-clock 口径,原样显示
|
||||||
|
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) },
|
||||||
|
{
|
||||||
|
title: '变动',
|
||||||
|
dataIndex: 'amount_cents',
|
||||||
|
render: (v: number) => (
|
||||||
|
<span style={{ color: v > 0 ? '#3f8600' : '#cf1322' }}>
|
||||||
|
{v > 0 ? '+' : ''}
|
||||||
|
{yuan(v)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '余额', dataIndex: 'balance_after_cents', render: yuan },
|
||||||
|
{ title: '类型', dataIndex: 'biz_type' },
|
||||||
|
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
||||||
|
];
|
||||||
|
|
||||||
const wdCols: ColumnsType<WithdrawOrder> = [
|
const wdCols: ColumnsType<WithdrawOrder> = [
|
||||||
{ title: '时间', dataIndex: 'created_at', render: dt },
|
// 提现单为 UTC 口径
|
||||||
|
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v) },
|
||||||
{ title: '金额', dataIndex: 'amount_cents', render: yuan },
|
{ title: '金额', dataIndex: 'amount_cents', render: yuan },
|
||||||
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag>{s}</Tag> },
|
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag>{s}</Tag> },
|
||||||
{ title: '单号', dataIndex: 'out_bill_no' },
|
{ title: '单号', dataIndex: 'out_bill_no' },
|
||||||
{ title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' },
|
{ title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const loadMoreLink = (list: { loadMore: () => void; nextCursor: number | null }) => (
|
||||||
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||||
|
<a
|
||||||
|
onClick={list.loadMore}
|
||||||
|
style={{ pointerEvents: list.nextCursor ? 'auto' : 'none', opacity: list.nextCursor ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>用户详情 #{uid}</h2>
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
|
||||||
|
<h2 style={{ margin: 0 }}>用户详情 #{uid}</h2>
|
||||||
|
<Space wrap>
|
||||||
|
{canCoins && <Button onClick={() => setCoinOpen(true)}>调金币</Button>}
|
||||||
|
{canCoins && <Button onClick={() => setCashOpen(true)}>调现金</Button>}
|
||||||
|
{canStatus && data.user.status !== 'deleted' && (
|
||||||
|
<Button danger={data.user.status === 'active'} onClick={toggleStatus}>
|
||||||
|
{data.user.status === 'active' ? '封禁' : '解封'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
<Card style={{ marginBottom: 16 }}>
|
<Card style={{ marginBottom: 16 }}>
|
||||||
<Descriptions column={3}>
|
<Descriptions column={3}>
|
||||||
<Descriptions.Item label="手机号">{data.user.phone}</Descriptions.Item>
|
<Descriptions.Item label="手机号">{data.user.phone}</Descriptions.Item>
|
||||||
@@ -64,7 +167,7 @@ export default function UserDetailPage() {
|
|||||||
<Descriptions.Item label="微信绑定">
|
<Descriptions.Item label="微信绑定">
|
||||||
{data.user.wechat_openid ? '已绑定' : '未绑定'}
|
{data.user.wechat_openid ? '已绑定' : '未绑定'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="注册时间">{dt(data.user.created_at)}</Descriptions.Item>
|
<Descriptions.Item label="注册时间">{formatUtcTime(data.user.created_at)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -115,11 +218,23 @@ export default function UserDetailPage() {
|
|||||||
loading={coins.loading}
|
loading={coins.loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
{loadMoreLink(coins)}
|
||||||
<a onClick={coins.loadMore} style={{ pointerEvents: coins.nextCursor ? 'auto' : 'none', opacity: coins.nextCursor ? 1 : 0.4 }}>
|
</>
|
||||||
加载更多
|
),
|
||||||
</a>
|
},
|
||||||
</div>
|
{
|
||||||
|
key: 'cash',
|
||||||
|
label: '现金流水',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={cashCols}
|
||||||
|
dataSource={cash.items}
|
||||||
|
loading={cash.loading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
{loadMoreLink(cash)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -138,6 +253,25 @@ export default function UserDetailPage() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdjustCoinModal
|
||||||
|
user={data.user}
|
||||||
|
open={coinOpen}
|
||||||
|
onClose={() => setCoinOpen(false)}
|
||||||
|
onDone={() => {
|
||||||
|
loadData();
|
||||||
|
coins.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AdjustCashModal
|
||||||
|
user={data.user}
|
||||||
|
open={cashOpen}
|
||||||
|
onClose={() => setCashOpen(false)}
|
||||||
|
onDone={() => {
|
||||||
|
loadData();
|
||||||
|
cash.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+217
-110
@@ -1,33 +1,42 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { Key } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import type { SorterResult } from 'antd/es/table/interface';
|
||||||
Button,
|
import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd';
|
||||||
Form,
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
message,
|
|
||||||
} from 'antd';
|
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
import { canDo } from '@/lib/auth';
|
||||||
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { useCursorList } from '@/lib/useCursorList';
|
||||||
import type { UserListItem, UserOverview } from '@/lib/types';
|
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||||
|
import type { UserListItem } from '@/lib/types';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
|
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
|
||||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
|
||||||
|
type SortField = 'id' | 'created_at' | 'last_login_at';
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
|
const [nickname, setNickname] = useState('');
|
||||||
const [status, setStatus] = useState<string | undefined>();
|
const [status, setStatus] = useState<string | undefined>();
|
||||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
const [channel, setChannel] = useState<string | undefined>();
|
||||||
|
const [regRange, setRegRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
|
const [loginRange, setLoginRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
|
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||||
|
// 排序(服务端):点列头即时生效,与「查询」按钮分开
|
||||||
|
const [sortBy, setSortBy] = useState<SortField>('id');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||||||
|
const filterKey = JSON.stringify(filters);
|
||||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<UserListItem>(
|
const { items, nextCursor, loading, loadMore, reload } = useCursorList<UserListItem>(
|
||||||
'/admin/api/users',
|
'/admin/api/users',
|
||||||
filters,
|
filters,
|
||||||
@@ -37,56 +46,56 @@ export default function UsersPage() {
|
|||||||
const canStatus = canDo(['operator']);
|
const canStatus = canDo(['operator']);
|
||||||
|
|
||||||
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
||||||
const [coinForm] = Form.useForm();
|
|
||||||
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
||||||
const [cashForm] = Form.useForm();
|
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||||
// 弹窗里展示该用户当前余额:列表行本身不带余额,开弹窗时拉一次 360 概览
|
|
||||||
const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null);
|
|
||||||
|
|
||||||
const openAdjust = (u: UserListItem, kind: 'coin' | 'cash') => {
|
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||||
setBalances(null);
|
useEffect(() => {
|
||||||
if (kind === 'coin') setCoinUser(u);
|
setSelectedRowKeys([]);
|
||||||
else setCashUser(u);
|
}, [filterKey]);
|
||||||
api
|
|
||||||
.get<UserOverview>(`/admin/api/users/${u.id}`)
|
const search = () =>
|
||||||
.then((r) => setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents }))
|
setApplied({
|
||||||
.catch(() => {});
|
phone: phone || undefined,
|
||||||
|
nickname: nickname || undefined,
|
||||||
|
status,
|
||||||
|
register_channel: channel || undefined,
|
||||||
|
created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined,
|
||||||
|
created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined,
|
||||||
|
last_login_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
|
||||||
|
last_login_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setPhone('');
|
||||||
|
setNickname('');
|
||||||
|
setStatus(undefined);
|
||||||
|
setChannel(undefined);
|
||||||
|
setRegRange(null);
|
||||||
|
setLoginRange(null);
|
||||||
|
setSortBy('id');
|
||||||
|
setSortOrder('desc');
|
||||||
|
setApplied({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = () => setFilters({ phone: phone || undefined, status });
|
const onTableChange = (
|
||||||
|
_pagination: unknown,
|
||||||
const submitCoins = async () => {
|
_filters: unknown,
|
||||||
const v = await coinForm.validateFields();
|
sorter: SorterResult<UserListItem> | SorterResult<UserListItem>[],
|
||||||
try {
|
) => {
|
||||||
await api.post(`/admin/api/users/${coinUser!.id}/coins`, v);
|
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||||
message.success('已调整金币');
|
if (s && s.order && s.field) {
|
||||||
setCoinUser(null);
|
setSortBy(s.field as SortField);
|
||||||
coinForm.resetFields();
|
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
|
||||||
} catch (e) {
|
} else {
|
||||||
message.error(errMsg(e));
|
// 取消排序 → 回默认(ID 倒序)
|
||||||
|
setSortBy('id');
|
||||||
|
setSortOrder('desc');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 发/扣现金:输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
|
const sortOrderOf = (field: SortField) =>
|
||||||
const submitCash = async () => {
|
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
||||||
const v = await cashForm.validateFields();
|
|
||||||
const amountCents = Math.round(Number(v.amount_yuan) * 100);
|
|
||||||
if (amountCents === 0) {
|
|
||||||
message.warning('金额不能为 0(且不小于 1 分)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await api.post(`/admin/api/users/${cashUser!.id}/cash`, {
|
|
||||||
amount_cents: amountCents,
|
|
||||||
reason: v.reason,
|
|
||||||
});
|
|
||||||
message.success(`已${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`);
|
|
||||||
setCashUser(null);
|
|
||||||
cashForm.resetFields();
|
|
||||||
} catch (e) {
|
|
||||||
message.error(errMsg(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleStatus = (u: UserListItem) => {
|
const toggleStatus = (u: UserListItem) => {
|
||||||
const next = u.status === 'active' ? 'disabled' : 'active';
|
const next = u.status === 'active' ? 'disabled' : 'active';
|
||||||
@@ -123,8 +132,64 @@ export default function UsersPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedUsers = items.filter((u) => selectedRowKeys.includes(u.id));
|
||||||
|
const selectedActive = selectedUsers.filter((u) => u.status === 'active');
|
||||||
|
const selectedDisabled = selectedUsers.filter((u) => u.status === 'disabled');
|
||||||
|
|
||||||
|
// 批量:循环调现有逐用户接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点
|
||||||
|
const runBulk = async (
|
||||||
|
targets: UserListItem[],
|
||||||
|
label: string,
|
||||||
|
call: (u: UserListItem) => Promise<unknown>,
|
||||||
|
) => {
|
||||||
|
const results = await Promise.allSettled(targets.map(call));
|
||||||
|
const ok = results.filter((r) => r.status === 'fulfilled').length;
|
||||||
|
const fail = results.length - ok;
|
||||||
|
if (fail) message.warning(`${label}完成:成功 ${ok}/${results.length},失败 ${fail}`);
|
||||||
|
else message.success(`${label}完成:${ok} 个`);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkStatus = (target: 'disabled' | 'active') => {
|
||||||
|
const targets = target === 'disabled' ? selectedActive : selectedDisabled;
|
||||||
|
const label = target === 'disabled' ? '批量封禁' : '批量解封';
|
||||||
|
if (!targets.length) {
|
||||||
|
message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认${label} ${targets.length} 个用户?`,
|
||||||
|
content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效',
|
||||||
|
okButtonProps: { danger: target === 'disabled' },
|
||||||
|
onOk: () =>
|
||||||
|
runBulk(targets, label, (u) =>
|
||||||
|
api.post(`/admin/api/users/${u.id}/status`, { status: target }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkDebugTrace = (enabled: boolean) => {
|
||||||
|
// 只对「非 deleted 且当前态不同」的选中用户操作,避免无谓请求
|
||||||
|
const targets = selectedUsers.filter(
|
||||||
|
(u) => u.status !== 'deleted' && u.debug_trace_enabled !== enabled,
|
||||||
|
);
|
||||||
|
const label = enabled ? '批量开调试链接' : '批量关调试链接';
|
||||||
|
if (!targets.length) {
|
||||||
|
message.warning('没有需要变更的选中用户');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认${label} ${targets.length} 个用户?`,
|
||||||
|
onOk: () =>
|
||||||
|
runBulk(targets, label, (u) =>
|
||||||
|
api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<UserListItem> = [
|
const columns: ColumnsType<UserListItem> = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
{ title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') },
|
||||||
{ title: '手机号', dataIndex: 'phone' },
|
{ title: '手机号', dataIndex: 'phone' },
|
||||||
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
|
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
|
||||||
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
|
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
|
||||||
@@ -137,16 +202,29 @@ export default function UsersPage() {
|
|||||||
{
|
{
|
||||||
title: '注册时间',
|
title: '注册时间',
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
width: 160,
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortOrderOf('created_at'),
|
||||||
|
render: (v: string) => formatUtcTime(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近登录',
|
||||||
|
dataIndex: 'last_login_at',
|
||||||
|
width: 160,
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: sortOrderOf('last_login_at'),
|
||||||
|
render: (v: string) => formatUtcTime(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'op',
|
key: 'op',
|
||||||
|
fixed: 'right',
|
||||||
|
width: 230,
|
||||||
render: (_: unknown, u: UserListItem) => (
|
render: (_: unknown, u: UserListItem) => (
|
||||||
<Space>
|
<Space onClick={(e) => e.stopPropagation()}>
|
||||||
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
||||||
{canCoins && <a onClick={() => openAdjust(u, 'coin')}>调金币</a>}
|
{canCoins && <a onClick={() => setCoinUser(u)}>调金币</a>}
|
||||||
{canCoins && <a onClick={() => openAdjust(u, 'cash')}>调现金</a>}
|
{canCoins && <a onClick={() => setCashUser(u)}>调现金</a>}
|
||||||
{canStatus && u.status !== 'deleted' && (
|
{canStatus && u.status !== 'deleted' && (
|
||||||
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
|
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
|
||||||
)}
|
)}
|
||||||
@@ -170,74 +248,103 @@ export default function UsersPage() {
|
|||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
onPressEnter={search}
|
onPressEnter={search}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 160 }}
|
style={{ width: 150 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="昵称(模糊)"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
onPressEnter={search}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 150 }}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="状态"
|
placeholder="状态"
|
||||||
value={status}
|
value={status}
|
||||||
onChange={setStatus}
|
onChange={setStatus}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 110 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'active', label: 'active' },
|
{ value: 'active', label: 'active' },
|
||||||
{ value: 'disabled', label: 'disabled' },
|
{ value: 'disabled', label: 'disabled' },
|
||||||
{ value: 'deleted', label: 'deleted' },
|
{ value: 'deleted', label: 'deleted' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="渠道"
|
||||||
|
value={channel}
|
||||||
|
onChange={setChannel}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 110 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'sms', label: 'sms' },
|
||||||
|
{ value: 'wechat', label: 'wechat' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<RangePicker
|
||||||
|
placeholder={['注册起', '注册止']}
|
||||||
|
value={regRange}
|
||||||
|
onChange={(v) => setRegRange(v as [Dayjs, Dayjs] | null)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<RangePicker
|
||||||
|
placeholder={['最近登录起', '最近登录止']}
|
||||||
|
value={loginRange}
|
||||||
|
onChange={(v) => setLoginRange(v as [Dayjs, Dayjs] | null)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
<Button type="primary" onClick={search}>
|
<Button type="primary" onClick={search}>
|
||||||
查询
|
查询
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Table rowKey="id" columns={columns} dataSource={items} loading={loading} pagination={false} />
|
|
||||||
|
{canStatus && selectedUsers.length > 0 && (
|
||||||
|
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
|
||||||
|
<span style={{ color: '#888' }}>
|
||||||
|
已选 {selectedUsers.length} 个(active {selectedActive.length}、disabled{' '}
|
||||||
|
{selectedDisabled.length})
|
||||||
|
</span>
|
||||||
|
<Button danger disabled={!selectedActive.length} onClick={() => bulkStatus('disabled')}>
|
||||||
|
批量封禁
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!selectedDisabled.length} onClick={() => bulkStatus('active')}>
|
||||||
|
批量解封
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => bulkDebugTrace(true)}>批量开调试链接</Button>
|
||||||
|
<Button onClick={() => bulkDebugTrace(false)}>批量关调试链接</Button>
|
||||||
|
<Button type="link" onClick={() => setSelectedRowKeys([])}>
|
||||||
|
清空选择
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
rowSelection={
|
||||||
|
canStatus
|
||||||
|
? {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: setSelectedRowKeys,
|
||||||
|
getCheckboxProps: (u) => ({ disabled: u.status === 'deleted' }),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
onChange={onTableChange}
|
||||||
|
/>
|
||||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||||
{nextCursor ? '加载更多' : '没有更多了'}
|
{nextCursor ? '加载更多' : '没有更多了'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
|
||||||
title={`调整金币 - ${coinUser?.phone ?? ''}`}
|
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
||||||
open={!!coinUser}
|
|
||||||
onOk={submitCoins}
|
|
||||||
onCancel={() => setCoinUser(null)}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<p style={{ color: '#888', marginBottom: 12 }}>
|
|
||||||
当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'}
|
|
||||||
</p>
|
|
||||||
<Form form={coinForm} layout="vertical">
|
|
||||||
<Form.Item name="amount" label="金币变动(正=增加,负=扣减)" rules={[{ required: true }]}>
|
|
||||||
<InputNumber style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
|
||||||
<Input.TextArea rows={2} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={`调现金 - ${cashUser?.phone ?? ''}`}
|
|
||||||
open={!!cashUser}
|
|
||||||
onOk={submitCash}
|
|
||||||
onCancel={() => setCashUser(null)}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<p style={{ color: '#888', marginBottom: 12 }}>
|
|
||||||
当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'}
|
|
||||||
</p>
|
|
||||||
<Form form={cashForm} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="amount_yuan"
|
|
||||||
label="现金变动(元,正=发放,负=扣减;不可扣成负)"
|
|
||||||
rules={[{ required: true }]}
|
|
||||||
>
|
|
||||||
<InputNumber style={{ width: '100%' }} step={0.01} precision={2} addonAfter="元" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
|
||||||
<Input.TextArea rows={2} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,9 @@ import {
|
|||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
||||||
import 'dayjs/locale/zh-cn';
|
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
import { canDo } from '@/lib/auth';
|
||||||
|
import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { useCursorList } from '@/lib/useCursorList';
|
||||||
import type {
|
import type {
|
||||||
AuditLog,
|
AuditLog,
|
||||||
@@ -52,19 +51,12 @@ import type {
|
|||||||
WxpayHealthCheck,
|
WxpayHealthCheck,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
|
||||||
dayjs.locale('zh-cn');
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
// 提现单 / 审计 / 用户均为 UTC 口径(server_default=func.now());现金流水为北京 wall-clock。
|
||||||
const apiTime = (v: string) => {
|
const dt = (v: string) => formatUtcTime(v);
|
||||||
const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
|
const ago = (v: string) => utcFromNow(v);
|
||||||
return dayjs(hasTimezone ? v : `${v}Z`);
|
|
||||||
};
|
|
||||||
const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm');
|
|
||||||
const ago = (v: string) => apiTime(v).fromNow();
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
reviewing: 'gold',
|
reviewing: 'gold',
|
||||||
@@ -161,7 +153,7 @@ function riskTags(detail: WithdrawDetail | null) {
|
|||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (!detail.order.user_name) tags.push('缺少提现实名');
|
if (!detail.order.user_name) tags.push('缺少提现实名');
|
||||||
if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`);
|
if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`);
|
||||||
if (detail.user && dayjs().diff(apiTime(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
|
if (detail.user && dayjs().diff(utcDayjs(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
|
||||||
if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
|
if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
|
||||||
if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) {
|
if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) {
|
||||||
tags.push('历史异常单');
|
tags.push('历史异常单');
|
||||||
@@ -181,6 +173,7 @@ export default function WithdrawsPage() {
|
|||||||
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
|
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
|
||||||
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
|
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
|
||||||
const [ledger, setLedger] = useState<WithdrawLedgerCheck | null>(null);
|
const [ledger, setLedger] = useState<WithdrawLedgerCheck | null>(null);
|
||||||
|
const [ledgerLoading, setLedgerLoading] = useState(false);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [detail, setDetail] = useState<WithdrawDetail | null>(null);
|
const [detail, setDetail] = useState<WithdrawDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
@@ -255,19 +248,28 @@ export default function WithdrawsPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadChecks = useCallback(async () => {
|
const loadHealth = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [{ data: healthData }, { data: ledgerData }] = await Promise.all([
|
const { data } = await api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check');
|
||||||
api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check'),
|
setHealth(data);
|
||||||
api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check'),
|
|
||||||
]);
|
|
||||||
setHealth(healthData);
|
|
||||||
setLedger(ledgerData);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(errMsg(e));
|
message.error(errMsg(e));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 账本校验是全表对账、相对重,故只在进页时拉一次 + 提供手动按钮,不随每次写操作重算。
|
||||||
|
const loadLedger = useCallback(async () => {
|
||||||
|
setLedgerLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check');
|
||||||
|
setLedger(data);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e));
|
||||||
|
} finally {
|
||||||
|
setLedgerLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDetail = useCallback(async (outBillNo: string) => {
|
const loadDetail = useCallback(async (outBillNo: string) => {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -282,8 +284,9 @@ export default function WithdrawsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSummary();
|
void loadSummary();
|
||||||
void loadChecks();
|
void loadHealth();
|
||||||
}, [loadChecks, loadSummary]);
|
void loadLedger();
|
||||||
|
}, [loadHealth, loadLedger, loadSummary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
@@ -292,7 +295,8 @@ export default function WithdrawsPage() {
|
|||||||
const refreshAfterChange = async (outBillNo?: string) => {
|
const refreshAfterChange = async (outBillNo?: string) => {
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
reload();
|
reload();
|
||||||
await Promise.all([loadSummary(), loadChecks()]);
|
// 账本校验偏重,写操作后不重算(只刷 summary + health);需要时点账本 Alert 上的「重新校验」。
|
||||||
|
await Promise.all([loadSummary(), loadHealth()]);
|
||||||
if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) {
|
if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) {
|
||||||
await loadDetail(outBillNo);
|
await loadDetail(outBillNo);
|
||||||
}
|
}
|
||||||
@@ -595,7 +599,8 @@ export default function WithdrawsPage() {
|
|||||||
},
|
},
|
||||||
{ title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan },
|
{ title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan },
|
||||||
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
||||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: dt },
|
// 现金流水是北京 wall-clock 口径,原样显示,不能当 UTC 再 +8
|
||||||
|
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const recentWithdrawColumns: ColumnsType<WithdrawOrder> = [
|
const recentWithdrawColumns: ColumnsType<WithdrawOrder> = [
|
||||||
@@ -686,6 +691,16 @@ export default function WithdrawsPage() {
|
|||||||
? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}`
|
? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}`
|
||||||
: ledgerIssues.join(';')
|
: ledgerIssues.join(';')
|
||||||
}
|
}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={ledgerLoading}
|
||||||
|
onClick={() => void loadLedger()}
|
||||||
|
>
|
||||||
|
重新校验
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
|
||||||
|
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { yuan } from '@/lib/format';
|
||||||
|
import type { UserOverview } from '@/lib/types';
|
||||||
|
|
||||||
|
export type AdjustTargetUser = { id: number; phone: string };
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
user: AdjustTargetUser | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone?: () => void; // 调整成功后回调(刷新余额/列表)
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceLine = (balances: { coin: number; cashCents: number } | null) => (
|
||||||
|
<p style={{ color: '#888', marginBottom: 12 }}>
|
||||||
|
当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 弹窗打开时拉一次该用户余额;用户切换/关闭时丢弃过期响应(alive 标记防竞态)。
|
||||||
|
function useBalances(userId: number | undefined, open: boolean) {
|
||||||
|
const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || userId == null) {
|
||||||
|
setBalances(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
setBalances(null);
|
||||||
|
api
|
||||||
|
.get<UserOverview>(`/admin/api/users/${userId}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (alive) setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [userId, open]);
|
||||||
|
return balances;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [mode, setMode] = useState<'delta' | 'set'>('delta');
|
||||||
|
const balances = useBalances(user?.id, open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMode('delta');
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
const v = await form.validateFields();
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/api/users/${user.id}/coins`, { mode, ...v });
|
||||||
|
message.success(mode === 'set' ? '已设置金币' : '已调整金币');
|
||||||
|
onClose();
|
||||||
|
onDone?.();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`调整金币 - ${user?.phone ?? ''}`}
|
||||||
|
open={open}
|
||||||
|
onOk={submit}
|
||||||
|
onCancel={onClose}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{balanceLine(balances)}
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="操作方式">
|
||||||
|
<Segmented
|
||||||
|
value={mode}
|
||||||
|
onChange={(v) => setMode(v as 'delta' | 'set')}
|
||||||
|
options={[
|
||||||
|
{ label: '增减', value: 'delta' },
|
||||||
|
{ label: '设为指定值', value: 'set' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="amount"
|
||||||
|
label={mode === 'set' ? '目标金币值(直接设为该值)' : '金币变动(正=增加,负=扣减)'}
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={mode === 'set' ? 0 : undefined} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [mode, setMode] = useState<'delta' | 'set'>('delta');
|
||||||
|
const balances = useBalances(user?.id, open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMode('delta');
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
// 输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
|
||||||
|
const submit = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
const v = await form.validateFields();
|
||||||
|
const amountCents = Math.round(Number(v.amount_yuan) * 100);
|
||||||
|
if (mode === 'delta' && amountCents === 0) {
|
||||||
|
message.warning('金额不能为 0(且不小于 1 分)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'set' && amountCents < 0) {
|
||||||
|
message.warning('目标金额不能为负');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/api/users/${user.id}/cash`, {
|
||||||
|
mode,
|
||||||
|
amount_cents: amountCents,
|
||||||
|
reason: v.reason,
|
||||||
|
});
|
||||||
|
message.success(
|
||||||
|
mode === 'set'
|
||||||
|
? `已设为 ¥${(amountCents / 100).toFixed(2)} 现金`
|
||||||
|
: `已${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`,
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
onDone?.();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`调现金 - ${user?.phone ?? ''}`}
|
||||||
|
open={open}
|
||||||
|
onOk={submit}
|
||||||
|
onCancel={onClose}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{balanceLine(balances)}
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="操作方式">
|
||||||
|
<Segmented
|
||||||
|
value={mode}
|
||||||
|
onChange={(v) => setMode(v as 'delta' | 'set')}
|
||||||
|
options={[
|
||||||
|
{ label: '增减', value: 'delta' },
|
||||||
|
{ label: '设为指定值', value: 'set' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="amount_yuan"
|
||||||
|
label={
|
||||||
|
mode === 'set'
|
||||||
|
? '目标现金(元,直接设为该值,须≥0)'
|
||||||
|
: '现金变动(元,正=发放,负=扣减;不可扣成负)'
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
step={0.01}
|
||||||
|
precision={2}
|
||||||
|
suffix="元"
|
||||||
|
min={mode === 'set' ? 0 : undefined}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 全站统一的金额 / 时间格式化。
|
||||||
|
//
|
||||||
|
// 后端有两种时间口径,序列化后从字符串无法区分,必须按字段语义选对函数:
|
||||||
|
// 1. UTC 口径:`server_default=func.now()` 的字段(用户/提现单/审计日志/广告发奖记录)。
|
||||||
|
// 入库是 UTC,SQLite 下序列化不带时区、Postgres 下带 +00:00。统一当 UTC 解析后转北京显示。
|
||||||
|
// 2. 北京 wall-clock 口径:钱包流水(coin_transaction / cash_transaction)。入库即
|
||||||
|
// `datetime.now(CN_TZ).replace(tzinfo=None)`,字面就是北京时间且无时区,**不能再做时区换算**。
|
||||||
|
// 历史上各页面混用 `new Date().toLocaleString`(按本地解析)与 `apiTime`(补 Z 当 UTC),
|
||||||
|
// 导致提现详情里现金流水时间快 8 小时。此模块收口,按口径区分。
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
const TZ = 'Asia/Shanghai';
|
||||||
|
|
||||||
|
/** 金额:分 → ¥元(两位小数)。 */
|
||||||
|
export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
|
||||||
|
|
||||||
|
const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
|
||||||
|
|
||||||
|
/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */
|
||||||
|
export const utcDayjs = (v: string) => dayjs(hasTz(v) ? v : `${v}Z`);
|
||||||
|
|
||||||
|
/** UTC 口径时间 → 北京时间显示(用户/提现单/审计/广告发奖记录)。 */
|
||||||
|
export function formatUtcTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string {
|
||||||
|
if (!v) return '-';
|
||||||
|
return utcDayjs(v).tz(TZ).format(fmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC 口径相对时间(如「3 分钟前」)。 */
|
||||||
|
export function utcFromNow(v?: string | null): string {
|
||||||
|
if (!v) return '-';
|
||||||
|
return utcDayjs(v).fromNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 北京 wall-clock 口径时间 → 原样格式化,不做时区换算(钱包金币/现金流水)。 */
|
||||||
|
export function formatWallTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string {
|
||||||
|
if (!v) return '-';
|
||||||
|
return dayjs(v).format(fmt);
|
||||||
|
}
|
||||||
+3
-2
@@ -217,8 +217,9 @@ export interface AdCoinFormula {
|
|||||||
export interface AdCoinAudit {
|
export interface AdCoinAudit {
|
||||||
date: string;
|
date: string;
|
||||||
formula: AdCoinFormula;
|
formula: AdCoinFormula;
|
||||||
total: number;
|
total: number; // 全量条数(不受 limit/only_mismatch 影响)
|
||||||
mismatch_count: number;
|
mismatch_count: number; // 全量不一致条数
|
||||||
|
truncated: boolean; // 展示集是否被 limit 截断
|
||||||
items: AdCoinAuditRow[];
|
items: AdCoinAuditRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user