Compare commits

..

4 Commits

Author SHA1 Message Date
marco 3c757aa838 feat(debug-trace): 用户列表加「开/关调试链接」开关
- UserListItem 加 debug_trace_enabled
- 操作列加开关,调 POST /admin/api/users/{id}/debug-trace(operator 守卫、二次确认,仿封禁)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:31:27 +08:00
marco 45b9c7140d Merge pull request 'feat: 运营后台完善微信提现审核列表和批量处理' (#3) from feature/withdraw-review-ops into main
Reviewed-on: #3
2026-06-09 01:55:47 +08:00
OuYingJun1024 e377234137 feat: 运营后台完善微信提现审核列表和批量处理
新增提现审核列表的关键词搜索、日期范围筛选、快捷筛选、排序控件和重置筛选能力。

新增提现详情抽屉,展示用户快照、风险提示、处理时间线、最近提现和现金流水。

支持批量通过、批量拒绝、批量刷新查单,并优化批量操作确认与结果提示。

修复筛选请求竞态,避免旧请求晚返回覆盖新的筛选结果。
2026-06-08 17:37:40 +08:00
marco d7c7d3e046 Merge pull request 'feat(dashboard): 首页轮播种子管理 + 三统计配置增强' (#1) from feat/home-data-config into main
Reviewed-on: #1
2026-06-07 23:17:53 +08:00
4 changed files with 1123 additions and 65 deletions
+24
View File
@@ -68,6 +68,25 @@ export default function UsersPage() {
});
};
const toggleDebugTrace = (u: UserListItem) => {
const next = !u.debug_trace_enabled;
Modal.confirm({
title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`,
content: next
? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障'
: undefined,
onOk: async () => {
try {
await api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled: next });
message.success('已更新调试链接权限');
reload();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const columns: ColumnsType<UserListItem> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '手机号', dataIndex: 'phone' },
@@ -94,6 +113,11 @@ export default function UsersPage() {
{canStatus && u.status !== 'deleted' && (
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
)}
{canStatus && u.status !== 'deleted' && (
<a onClick={() => toggleDebugTrace(u)}>
{u.debug_trace_enabled ? '关调试链接' : '开调试链接'}
</a>
)}
</Space>
),
},
File diff suppressed because it is too large Load Diff
+80 -1
View File
@@ -27,6 +27,8 @@ export interface UserListItem {
nickname: string | null;
register_channel: string;
status: string;
// 调试链接权限:开了的用户在 App 比价结果页/记录页可复制 price.shaguabijia.com 的 trace 调试链接
debug_trace_enabled: boolean;
wechat_openid: string | null;
created_at: string;
last_login_at: string;
@@ -71,7 +73,8 @@ export interface WithdrawOrder {
user_id: number;
out_bill_no: string;
amount_cents: number;
status: string; // pending / success / failed
user_name: string | null;
status: string; // reviewing / pending / success / failed / rejected
wechat_state: string | null;
transfer_bill_no: string | null;
fail_reason: string | null;
@@ -79,6 +82,82 @@ export interface WithdrawOrder {
updated_at: string;
}
export interface WithdrawSummary {
reviewing_count: number;
reviewing_amount_cents: number;
pending_count: number;
failed_count: number;
today_success_count: number;
today_success_amount_cents: number;
today_rejected_count: number;
}
export interface WithdrawUserSnapshot {
id: number;
phone: string;
nickname: string | null;
status: string;
wechat_nickname: string | null;
wechat_avatar_url: string | null;
created_at: string;
last_login_at: string;
cash_balance_cents: number;
withdraw_total: number;
withdraw_success_cents: number;
}
export interface WithdrawDetail {
order: WithdrawOrder;
user: WithdrawUserSnapshot | null;
risk_flags: string[];
risk_score: number;
recent_withdraws: WithdrawOrder[];
recent_cash_transactions: CashTxn[];
audit_logs: AuditLog[];
}
export interface WithdrawBulkItemResult {
out_bill_no: string;
ok: boolean;
status: string | null;
error: string | null;
}
export interface WithdrawBulkResult {
total: number;
success: number;
failed: number;
items: WithdrawBulkItemResult[];
}
export interface WithdrawLedgerCheck {
ok: boolean;
cash_balance_total_cents: number;
cash_transaction_total_cents: number;
balance_diff_cents: number;
missing_withdraw_txn_count: number;
missing_refund_txn_count: number;
duplicate_refund_txn_count: number;
refund_txn_on_non_terminal_count: number;
}
export interface WxpayHealthCheck {
ok: boolean;
wxpay_configured: boolean;
wxpay_auth_configured: boolean;
private_key_path: string;
private_key_exists: boolean;
private_key_loadable: boolean;
public_key_path: string;
public_key_exists: boolean;
public_key_loadable: boolean;
auth_notify_url_configured: boolean;
auto_reconcile_enabled: boolean;
auto_reconcile_interval_sec: number;
auto_reconcile_older_than_minutes: number;
issues: string[];
}
export interface Feedback {
id: number;
user_id: number;
+8 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api';
import type { CursorPage } from './types';
@@ -12,26 +12,32 @@ export function useCursorList<T>(url: string, filters: Record<string, unknown>,
const [items, setItems] = useState<T[]>([]);
const [nextCursor, setNextCursor] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const requestSeq = useRef(0);
const filtersKey = JSON.stringify(filters);
const load = useCallback(
async (cursor: number | null) => {
const seq = requestSeq.current + 1;
requestSeq.current = seq;
setLoading(true);
try {
const params: Record<string, unknown> = { ...JSON.parse(filtersKey), limit };
if (cursor != null) params.cursor = cursor;
const { data } = await api.get<CursorPage<T>>(url, { params });
if (requestSeq.current !== seq) return;
setItems((prev) => (cursor == null ? data.items : [...prev, ...data.items]));
setNextCursor(data.next_cursor);
} finally {
setLoading(false);
if (requestSeq.current === seq) setLoading(false);
}
},
[url, filtersKey, limit],
);
useEffect(() => {
setItems([]);
setNextCursor(null);
load(null);
}, [load]);