From e8129cb93ed8bc5355f7e8d1b1f0127f2bbb15d0 Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Sat, 13 Jun 2026 21:41:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin-web):=20=E4=BD=99=E9=A2=9D=E8=AE=BE?= =?UTF-8?q?=E5=80=BC/=E6=89=B9=E9=87=8F=E3=80=81=E6=97=B6=E5=8C=BA?= =?UTF-8?q?=E4=B8=8E=E5=A4=8D=E7=94=A8=E6=95=B4=E9=A1=BF=E3=80=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=88=97=E8=A1=A8=E6=8E=92=E5=BA=8F=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调金币/现金:新增「设为指定值」模式;修 InputNumber addonAfter 废弃告警; 弹窗抽成共享组件 AdjustBalanceModals(列表页/详情页复用,内含防竞态余额拉取) - 新增 lib/format.ts 统一金额/时间格式化:区分 UTC 字段与钱包流水(北京 wall-clock), 修正提现详情现金流水快 8 小时、用户注册时间慢 8 小时等偏差 - 提现页:账本校验(ledger-check)改按需触发,写操作后不再重算;现金流水时区修正 - 用户详情页:加载失败 Result 兜底(不再永久转圈)+ 现金流水 tab + 调金币/现金/封禁入口 - 金币审计页:加「只看不一致」过滤;截断提示改用后端精确 truncated 旗标 - 用户列表:注册/最近登录/ID 列排序 + 渠道/昵称/时间范围筛选 + 批量封禁解封/开关调试链接 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(main)/ad-audit/page.tsx | 50 ++-- src/app/(main)/users/[id]/page.tsx | 170 +++++++++++-- src/app/(main)/users/page.tsx | 327 ++++++++++++++++--------- src/app/(main)/withdraws/page.tsx | 63 +++-- src/components/AdjustBalanceModals.tsx | 201 +++++++++++++++ src/lib/format.ts | 47 ++++ src/lib/types.ts | 5 +- 7 files changed, 694 insertions(+), 169 deletions(-) create mode 100644 src/components/AdjustBalanceModals.tsx create mode 100644 src/lib/format.ts diff --git a/src/app/(main)/ad-audit/page.tsx b/src/app/(main)/ad-audit/page.tsx index ef9c4ef..3d5cdad 100644 --- a/src/app/(main)/ad-audit/page.tsx +++ b/src/app/(main)/ad-audit/page.tsx @@ -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 = { reward_video: { color: 'blue', label: '看视频' }, feed: { color: 'purple', label: '信息流' }, @@ -48,6 +48,8 @@ export default function AdAuditPage() { const [scene, setScene] = useState(); const [limit, setLimit] = useState(100); const [data, setData] = useState(null); + const [queriedLimit, setQueriedLimit] = useState(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 {t.label}; }, }, - { 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} 条` }))} /> + setOnlyMismatch(e.target.checked)}> + 只看不一致(✗) + @@ -193,17 +200,30 @@ export default function AdAuditPage() { {data && (
- {data.mismatch_count > 0 ? ( - - ) : ( - - )} + + {data.mismatch_count > 0 ? ( + + ) : ( + + )} + {onlyMismatch && ( + + )} + {data.truncated && ( + + )} +
)} diff --git a/src/app/(main)/users/[id]/page.tsx b/src/app/(main)/users/[id]/page.tsx index a53f7f2..1a49a32 100644 --- a/src/app/(main)/users/[id]/page.tsx +++ b/src/app/(main)/users/[id]/page.tsx @@ -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(null); + const [err, setErr] = useState(null); + const [coinOpen, setCoinOpen] = useState(false); + const [cashOpen, setCashOpen] = useState(false); - useEffect(() => { - api.get(`/admin/api/users/${uid}`).then((r) => setData(r.data)); + const loadData = useCallback(() => { + setErr(null); + api + .get(`/admin/api/users/${uid}`) + .then((r) => setData(r.data)) + .catch((e) => setErr(errMsg(e, '加载用户详情失败'))); }, [uid]); + useEffect(() => { + loadData(); + }, [loadData]); + const coins = useCursorList('/admin/api/wallet/coin-transactions', { user_id: uid }); + const cash = useCursorList('/admin/api/wallet/cash-transactions', { user_id: uid }); const withdraws = useCursorList('/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 ( + + 重试 + + } + /> + ); + } if (!data) return ; const coinCols: ColumnsType = [ - { 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 = [ + // 现金流水为北京 wall-clock 口径,原样显示 + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) }, + { + title: '变动', + dataIndex: 'amount_cents', + render: (v: number) => ( + 0 ? '#3f8600' : '#cf1322' }}> + {v > 0 ? '+' : ''} + {yuan(v)} + + ), + }, + { title: '余额', dataIndex: 'balance_after_cents', render: yuan }, + { title: '类型', dataIndex: 'biz_type' }, + { title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' }, + ]; + const wdCols: ColumnsType = [ - { 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) => {s} }, { title: '单号', dataIndex: 'out_bill_no' }, { title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' }, ]; + const loadMoreLink = (list: { loadMore: () => void; nextCursor: number | null }) => ( + + ); + return (
-

用户详情 #{uid}

+ +

用户详情 #{uid}

+ + {canCoins && } + {canCoins && } + {canStatus && data.user.status !== 'deleted' && ( + + )} + +
{data.user.phone} @@ -64,7 +167,7 @@ export default function UserDetailPage() { {data.user.wechat_openid ? '已绑定' : '未绑定'} - {dt(data.user.created_at)} + {formatUtcTime(data.user.created_at)} @@ -115,11 +218,23 @@ export default function UserDetailPage() { loading={coins.loading} pagination={false} /> - + {loadMoreLink(coins)} + + ), + }, + { + key: 'cash', + label: '现金流水', + children: ( + <> + + {loadMoreLink(cash)} ), }, @@ -138,6 +253,25 @@ export default function UserDetailPage() { }, ]} /> + + setCoinOpen(false)} + onDone={() => { + loadData(); + coins.reload(); + }} + /> + setCashOpen(false)} + onDone={() => { + loadData(); + cash.reload(); + }} + /> ); } diff --git a/src/app/(main)/users/page.tsx b/src/app/(main)/users/page.tsx index 79d8214..0ee25f3 100644 --- a/src/app/(main)/users/page.tsx +++ b/src/app/(main)/users/page.tsx @@ -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 = { 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(); - const [filters, setFilters] = useState>({}); + const [channel, setChannel] = useState(); + const [regRange, setRegRange] = useState<[Dayjs, Dayjs] | null>(null); + const [loginRange, setLoginRange] = useState<[Dayjs, Dayjs] | null>(null); + const [applied, setApplied] = useState>({}); + // 排序(服务端):点列头即时生效,与「查询」按钮分开 + const [sortBy, setSortBy] = useState('id'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const filterKey = JSON.stringify(filters); const { items, nextCursor, loading, loadMore, reload } = useCursorList( '/admin/api/users', filters, @@ -37,56 +46,56 @@ export default function UsersPage() { const canStatus = canDo(['operator']); const [coinUser, setCoinUser] = useState(null); - const [coinForm] = Form.useForm(); const [cashUser, setCashUser] = useState(null); - const [cashForm] = Form.useForm(); - // 弹窗里展示该用户当前余额:列表行本身不带余额,开弹窗时拉一次 360 概览 - const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const openAdjust = (u: UserListItem, kind: 'coin' | 'cash') => { - setBalances(null); - if (kind === 'coin') setCoinUser(u); - else setCashUser(u); - api - .get(`/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 | SorterResult[], + ) => { + 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, + ) => { + 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 = [ - { 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) => ( - + e.stopPropagation()}> router.push(`/users/${u.id}`)}>详情 - {canCoins && openAdjust(u, 'coin')}>调金币} - {canCoins && openAdjust(u, 'cash')}>调现金} + {canCoins && setCoinUser(u)}>调金币} + {canCoins && setCashUser(u)}>调现金} {canStatus && u.status !== 'deleted' && ( toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'} )} @@ -170,74 +248,103 @@ export default function UsersPage() { onChange={(e) => setPhone(e.target.value)} onPressEnter={search} allowClear - style={{ width: 160 }} + style={{ width: 150 }} + /> + setNickname(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} /> + setRegRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + setLoginRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + -
+ + {canStatus && selectedUsers.length > 0 && ( + + + 已选 {selectedUsers.length} 个(active {selectedActive.length}、disabled{' '} + {selectedDisabled.length}) + + + + + + + + )} + +
({ disabled: u.status === 'deleted' }), + } + : undefined + } + columns={columns} + dataSource={items} + loading={loading} + pagination={false} + scroll={{ x: 1100 }} + onChange={onTableChange} + />
- setCoinUser(null)} - destroyOnHidden - > -

- 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'} -

-
- - - - - - - -
- - setCashUser(null)} - destroyOnHidden - > -

- 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'} -

-
- - - - - - - -
+ setCoinUser(null)} /> + setCashUser(null)} /> ); } diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index b869749..aef4d36 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -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 = { 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(null); const [health, setHealth] = useState(null); const [ledger, setLedger] = useState(null); + const [ledgerLoading, setLedgerLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [detail, setDetail] = useState(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('/admin/api/withdraws/health-check'), - api.get('/admin/api/withdraws/ledger-check'), - ]); - setHealth(healthData); - setLedger(ledgerData); + const { data } = await api.get('/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('/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 = [ @@ -686,6 +691,16 @@ export default function WithdrawsPage() { ? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}` : ledgerIssues.join(';') } + action={ + + } /> )} diff --git a/src/components/AdjustBalanceModals.tsx b/src/components/AdjustBalanceModals.tsx new file mode 100644 index 0000000..5192b1f --- /dev/null +++ b/src/components/AdjustBalanceModals.tsx @@ -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) => ( +

+ 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'} +

+); + +// 弹窗打开时拉一次该用户余额;用户切换/关闭时丢弃过期响应(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(`/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 ( + + {balanceLine(balances)} +
+ + setMode(v as 'delta' | 'set')} + options={[ + { label: '增减', value: 'delta' }, + { label: '设为指定值', value: 'set' }, + ]} + /> + + + + + + + + +
+ ); +} + +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 ( + + {balanceLine(balances)} +
+ + setMode(v as 'delta' | 'set')} + options={[ + { label: '增减', value: 'delta' }, + { label: '设为指定值', value: 'set' }, + ]} + /> + + + + + + + + +
+ ); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..8a621e3 --- /dev/null +++ b/src/lib/format.ts @@ -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); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 23b151f..1555f87 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[]; }