Compare commits

..

2 Commits

Author SHA1 Message Date
linkeyu 52acff8243 修复:同步反馈与提现审核状态
补充用户反馈页自动刷新,并让公共红点支持邀请提现与其他提现独立统计。
2026-07-27 10:20:03 +08:00
linkeyu 8c5e6bcef8 修复:同步低价审核红点与列表数据
进入页面、窗口重新聚焦及标签页恢复可见时刷新审核数据,并在页面可见期间每分钟同步一次,避免已无待审核记录时仍显示旧红点。
2026-07-27 10:09:56 +08:00
5 changed files with 107 additions and 24 deletions
+2 -2
View File
@@ -237,9 +237,9 @@ export default function ComparisonRecordsPage() {
key: 'user',
width: 140,
render: (_, r) => (
<div style={{ color: '#1677ff' }}>
<div>
<div>{r.phone || `#${r.user_id}`}</div>
{r.nickname && <div style={{ fontSize: 12 }}>{r.nickname}</div>}
{r.nickname && <div style={{ color: '#999', fontSize: 12 }}>{r.nickname}</div>}
</div>
),
},
+32 -5
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import {
@@ -35,6 +35,7 @@ import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const REWARD_MAX = 10000;
const REVIEW_PAGE_POLL_INTERVAL_MS = 60_000;
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
@@ -178,6 +179,10 @@ export default function FeedbacksPage() {
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<Feedback>('/admin/api/feedbacks', filters);
const reloadRef = useRef(reload);
useEffect(() => {
reloadRef.current = reload;
}, [reload]);
const canReview = canDo(['operator']);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
@@ -195,18 +200,40 @@ export default function FeedbacksPage() {
}, [items]);
const [summary, setSummary] = useState<FeedbackSummary | null>(null);
const loadSummary = async () => {
const loadSummary = useCallback(async () => {
try {
const { data } = await api.get<FeedbackSummary>('/admin/api/feedbacks/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
};
useEffect(() => {
loadSummary();
}, []);
const refreshPageData = useCallback(() => {
void reloadRef.current();
void loadSummary();
}, [loadSummary]);
useEffect(() => {
void loadSummary();
const onWindowFocus = () => refreshPageData();
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') refreshPageData();
};
const pollTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') refreshPageData();
}, REVIEW_PAGE_POLL_INTERVAL_MS);
window.addEventListener('focus', onWindowFocus);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.clearInterval(pollTimer);
window.removeEventListener('focus', onWindowFocus);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [loadSummary, refreshPageData]);
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
+36 -11
View File
@@ -37,6 +37,8 @@ import type {
WithdrawSummary,
} from '@/lib/types';
const REVIEW_BADGE_POLL_INTERVAL_MS = 60_000;
const { Sider, Header, Content } = Layout;
type NavItem = {
@@ -144,8 +146,13 @@ export default function MainLayout({ children }: { children: React.ReactNode })
const version = (reviewRefreshVersion.current[key] ?? 0) + 1;
reviewRefreshVersion.current[key] = version;
let count: number;
if (key === '/withdraws') {
count = (await api.get<WithdrawSummary>('/admin/api/withdraws/summary')).data.reviewing_count;
if (key === '/withdraws' || key === '/invite-withdraws') {
const source = key === '/invite-withdraws' ? 'invite_cash' : 'coin_cash';
count = (
await api.get<WithdrawSummary>('/admin/api/withdraws/summary', {
params: { source },
})
).data.reviewing_count;
} else if (key === '/price-reports') {
count = (await api.get<PriceReportSummary>('/admin/api/price-reports/summary')).data.pending;
} else {
@@ -156,21 +163,39 @@ export default function MainLayout({ children }: { children: React.ReactNode })
};
const refreshSafely = (key: ReviewBadgeKey) => refresh(key).catch(() => {});
void Promise.all([
refreshSafely('/withdraws'),
refreshSafely('/price-reports'),
refreshSafely('/feedbacks'),
]);
const refreshAll = () => {
void Promise.all([
refreshSafely('/withdraws'),
refreshSafely('/invite-withdraws'),
refreshSafely('/price-reports'),
refreshSafely('/feedbacks'),
]);
};
refreshAll();
const onReviewCompleted = (event: Event) => {
const key = (event as CustomEvent<ReviewBadgeKey>).detail;
if (key) void refreshSafely(key);
};
window.addEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted);
return () => {
window.removeEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted);
const onWindowFocus = () => refreshAll();
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') refreshAll();
};
}, [admin]);
const pollTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') refreshAll();
}, REVIEW_BADGE_POLL_INTERVAL_MS);
window.addEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted);
window.addEventListener('focus', onWindowFocus);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.clearInterval(pollTimer);
window.removeEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted);
window.removeEventListener('focus', onWindowFocus);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [admin, pathname]);
if (!admin) return null; // 守卫期间不闪烁内容
+32 -5
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
App,
Button,
@@ -30,6 +30,7 @@ import type { PriceReport, PriceReportSummary } from '@/lib/types';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text } = Typography;
const REVIEW_PAGE_POLL_INTERVAL_MS = 60_000;
// 后端 created_at/reviewed_at 存的是北京 wall-clock(naive,同 savings/comparison),
// 直接本地解析、不加 Z(否则会差 8h)。
@@ -150,6 +151,10 @@ export default function PriceReportsPage() {
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<PriceReport>('/admin/api/price-reports', filters);
const reloadRef = useRef(reload);
useEffect(() => {
reloadRef.current = reload;
}, [reload]);
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
@@ -181,18 +186,40 @@ export default function PriceReportsPage() {
setSelectedRowKeys((keys) => keys.filter((id) => visiblePendingIds.has(id)));
}, [items]);
const loadSummary = async () => {
const loadSummary = useCallback(async () => {
try {
const { data } = await api.get<PriceReportSummary>('/admin/api/price-reports/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
};
useEffect(() => {
loadSummary();
}, []);
const refreshPageData = useCallback(() => {
void reloadRef.current();
void loadSummary();
}, [loadSummary]);
useEffect(() => {
void loadSummary();
const onWindowFocus = () => refreshPageData();
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') refreshPageData();
};
const pollTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') refreshPageData();
}, REVIEW_PAGE_POLL_INTERVAL_MS);
window.addEventListener('focus', onWindowFocus);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.clearInterval(pollTimer);
window.removeEventListener('focus', onWindowFocus);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [loadSummary, refreshPageData]);
const refreshAfterChange = () => {
reload();
loadSummary();
+5 -1
View File
@@ -1,4 +1,8 @@
export type ReviewBadgeKey = '/withdraws' | '/price-reports' | '/feedbacks';
export type ReviewBadgeKey =
| '/invite-withdraws'
| '/withdraws'
| '/price-reports'
| '/feedbacks';
export const REVIEW_BADGE_REFRESH_EVENT = 'review-badge-refresh';