Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8129cb93e |
@@ -6,6 +6,7 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Select,
|
||||
@@ -17,10 +18,9 @@ import {
|
||||
} from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
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 }> = {
|
||||
reward_video: { color: 'blue', label: '看视频' },
|
||||
feed: { color: 'purple', label: '信息流' },
|
||||
@@ -48,6 +48,8 @@ export default function AdAuditPage() {
|
||||
const [scene, setScene] = useState<string | undefined>();
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
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 load = useCallback(async () => {
|
||||
@@ -59,15 +61,17 @@ export default function AdAuditPage() {
|
||||
user_id: userId ?? undefined,
|
||||
scene: scene ?? undefined,
|
||||
limit,
|
||||
only_mismatch: onlyMismatch || undefined,
|
||||
},
|
||||
});
|
||||
setData(res.data);
|
||||
setQueriedLimit(limit);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date, userId, scene, limit]);
|
||||
}, [date, userId, scene, limit, onlyMismatch]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -85,7 +89,7 @@ export default function AdAuditPage() {
|
||||
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: '状态',
|
||||
@@ -186,6 +190,9 @@ export default function AdAuditPage() {
|
||||
style={{ width: 120 }}
|
||||
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>
|
||||
@@ -193,17 +200,30 @@ export default function AdAuditPage() {
|
||||
|
||||
{data && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{data.mismatch_count > 0 ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="success"
|
||||
message={`共 ${data.total} 条,全部按公式发放(无不一致)`}
|
||||
/>
|
||||
)}
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{data.mismatch_count > 0 ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="success" message={`共 ${data.total} 条,全部按公式发放(无不一致)`} />
|
||||
)}
|
||||
{onlyMismatch && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前只展示不一致(✗)行;上方统计仍为全量(共 ${data.total} 条)。`}
|
||||
/>
|
||||
)}
|
||||
{data.truncated && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`展示明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/场景/日期缩小范围,或调大「条数」。统计数字不受影响。`}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,32 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Card, Col, Descriptions, Row, Spin, Statistic, Table, Tabs, Tag } from 'antd';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
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 type { CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||
|
||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const uid = Number(params.id);
|
||||
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(() => {
|
||||
api.get<UserOverview>(`/admin/api/users/${uid}`).then((r) => setData(r.data));
|
||||
const loadData = useCallback(() => {
|
||||
setErr(null);
|
||||
api
|
||||
.get<UserOverview>(`/admin/api/users/${uid}`)
|
||||
.then((r) => setData(r.data))
|
||||
.catch((e) => setErr(errMsg(e, '加载用户详情失败')));
|
||||
}, [uid]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
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 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 }} />;
|
||||
|
||||
const coinCols: ColumnsType<CoinTxn> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt },
|
||||
// 金币流水为北京 wall-clock 口径,原样显示
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) },
|
||||
{
|
||||
title: '变动',
|
||||
dataIndex: 'amount',
|
||||
@@ -42,17 +104,58 @@ export default function UserDetailPage() {
|
||||
{ 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> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt },
|
||||
// 提现单为 UTC 口径
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v) },
|
||||
{ title: '金额', dataIndex: 'amount_cents', render: yuan },
|
||||
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag>{s}</Tag> },
|
||||
{ title: '单号', dataIndex: 'out_bill_no' },
|
||||
{ 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 (
|
||||
<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 }}>
|
||||
<Descriptions column={3}>
|
||||
<Descriptions.Item label="手机号">{data.user.phone}</Descriptions.Item>
|
||||
@@ -64,7 +167,7 @@ export default function UserDetailPage() {
|
||||
<Descriptions.Item label="微信绑定">
|
||||
{data.user.wechat_openid ? '已绑定' : '未绑定'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">{dt(data.user.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">{formatUtcTime(data.user.created_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
@@ -115,11 +218,23 @@ export default function UserDetailPage() {
|
||||
loading={coins.loading}
|
||||
pagination={false}
|
||||
/>
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<a onClick={coins.loadMore} style={{ pointerEvents: coins.nextCursor ? 'auto' : 'none', opacity: coins.nextCursor ? 1 : 0.4 }}>
|
||||
加载更多
|
||||
</a>
|
||||
</div>
|
||||
{loadMoreLink(coins)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+217
-110
@@ -1,33 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
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 yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
|
||||
type SortField = 'id' | 'created_at' | 'last_login_at';
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
|
||||
const [phone, setPhone] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
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>(
|
||||
'/admin/api/users',
|
||||
filters,
|
||||
@@ -37,56 +46,56 @@ export default function UsersPage() {
|
||||
const canStatus = canDo(['operator']);
|
||||
|
||||
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
||||
const [coinForm] = Form.useForm();
|
||||
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
||||
const [cashForm] = Form.useForm();
|
||||
// 弹窗里展示该用户当前余额:列表行本身不带余额,开弹窗时拉一次 360 概览
|
||||
const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
|
||||
const openAdjust = (u: UserListItem, kind: 'coin' | 'cash') => {
|
||||
setBalances(null);
|
||||
if (kind === 'coin') setCoinUser(u);
|
||||
else setCashUser(u);
|
||||
api
|
||||
.get<UserOverview>(`/admin/api/users/${u.id}`)
|
||||
.then((r) => setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents }))
|
||||
.catch(() => {});
|
||||
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [filterKey]);
|
||||
|
||||
const search = () =>
|
||||
setApplied({
|
||||
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 submitCoins = async () => {
|
||||
const v = await coinForm.validateFields();
|
||||
try {
|
||||
await api.post(`/admin/api/users/${coinUser!.id}/coins`, v);
|
||||
message.success('已调整金币');
|
||||
setCoinUser(null);
|
||||
coinForm.resetFields();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
const onTableChange = (
|
||||
_pagination: unknown,
|
||||
_filters: unknown,
|
||||
sorter: SorterResult<UserListItem> | SorterResult<UserListItem>[],
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
if (s && s.order && s.field) {
|
||||
setSortBy(s.field as SortField);
|
||||
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
|
||||
} else {
|
||||
// 取消排序 → 回默认(ID 倒序)
|
||||
setSortBy('id');
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
// 发/扣现金:输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
|
||||
const submitCash = async () => {
|
||||
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 sortOrderOf = (field: SortField) =>
|
||||
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
||||
|
||||
const toggleStatus = (u: UserListItem) => {
|
||||
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> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') },
|
||||
{ title: '手机号', dataIndex: 'phone' },
|
||||
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
|
||||
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
|
||||
@@ -137,16 +202,29 @@ export default function UsersPage() {
|
||||
{
|
||||
title: '注册时间',
|
||||
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: '操作',
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 230,
|
||||
render: (_: unknown, u: UserListItem) => (
|
||||
<Space>
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
||||
{canCoins && <a onClick={() => openAdjust(u, 'coin')}>调金币</a>}
|
||||
{canCoins && <a onClick={() => openAdjust(u, 'cash')}>调现金</a>}
|
||||
{canCoins && <a onClick={() => setCoinUser(u)}>调金币</a>}
|
||||
{canCoins && <a onClick={() => setCashUser(u)}>调现金</a>}
|
||||
{canStatus && u.status !== 'deleted' && (
|
||||
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
|
||||
)}
|
||||
@@ -170,74 +248,103 @@ export default function UsersPage() {
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="昵称(模糊)"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'active', label: 'active' },
|
||||
{ value: 'disabled', label: 'disabled' },
|
||||
{ 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>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
</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' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={`调整金币 - ${coinUser?.phone ?? ''}`}
|
||||
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>
|
||||
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
|
||||
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,9 @@ import {
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type {
|
||||
AuditLog,
|
||||
@@ -52,19 +51,12 @@ import type {
|
||||
WxpayHealthCheck,
|
||||
} from '@/lib/types';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
const apiTime = (v: string) => {
|
||||
const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(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();
|
||||
// 提现单 / 审计 / 用户均为 UTC 口径(server_default=func.now());现金流水为北京 wall-clock。
|
||||
const dt = (v: string) => formatUtcTime(v);
|
||||
const ago = (v: string) => utcFromNow(v);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
reviewing: 'gold',
|
||||
@@ -161,7 +153,7 @@ function riskTags(detail: WithdrawDetail | null) {
|
||||
const tags: string[] = [];
|
||||
if (!detail.order.user_name) tags.push('缺少提现实名');
|
||||
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.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) {
|
||||
tags.push('历史异常单');
|
||||
@@ -181,6 +173,7 @@ export default function WithdrawsPage() {
|
||||
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
|
||||
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
|
||||
const [ledger, setLedger] = useState<WithdrawLedgerCheck | null>(null);
|
||||
const [ledgerLoading, setLedgerLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<WithdrawDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
@@ -255,19 +248,28 @@ export default function WithdrawsPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadChecks = useCallback(async () => {
|
||||
const loadHealth = useCallback(async () => {
|
||||
try {
|
||||
const [{ data: healthData }, { data: ledgerData }] = await Promise.all([
|
||||
api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check'),
|
||||
api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check'),
|
||||
]);
|
||||
setHealth(healthData);
|
||||
setLedger(ledgerData);
|
||||
const { data } = await api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check');
|
||||
setHealth(data);
|
||||
} catch (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) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
@@ -282,8 +284,9 @@ export default function WithdrawsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
void loadChecks();
|
||||
}, [loadChecks, loadSummary]);
|
||||
void loadHealth();
|
||||
void loadLedger();
|
||||
}, [loadHealth, loadLedger, loadSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
@@ -292,7 +295,8 @@ export default function WithdrawsPage() {
|
||||
const refreshAfterChange = async (outBillNo?: string) => {
|
||||
setSelectedRowKeys([]);
|
||||
reload();
|
||||
await Promise.all([loadSummary(), loadChecks()]);
|
||||
// 账本校验偏重,写操作后不重算(只刷 summary + health);需要时点账本 Alert 上的「重新校验」。
|
||||
await Promise.all([loadSummary(), loadHealth()]);
|
||||
if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) {
|
||||
await loadDetail(outBillNo);
|
||||
}
|
||||
@@ -595,7 +599,8 @@ export default function WithdrawsPage() {
|
||||
},
|
||||
{ title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan },
|
||||
{ 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> = [
|
||||
@@ -686,6 +691,16 @@ export default function WithdrawsPage() {
|
||||
? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}`
|
||||
: ledgerIssues.join(';')
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ledgerLoading}
|
||||
onClick={() => void loadLedger()}
|
||||
>
|
||||
重新校验
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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 {
|
||||
date: string;
|
||||
formula: AdCoinFormula;
|
||||
total: number;
|
||||
mismatch_count: number;
|
||||
total: number; // 全量条数(不受 limit/only_mismatch 影响)
|
||||
mismatch_count: number; // 全量不一致条数
|
||||
truncated: boolean; // 展示集是否被 limit 截断
|
||||
items: AdCoinAuditRow[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user