diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index f773e96..f94ba01 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -20,6 +20,7 @@ import type { Dayjs } from 'dayjs'; import { api } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatWallTime } from '@/lib/format'; +import { refreshReviewBadge } from '@/lib/reviewBadge'; import { usePagedList } from '@/lib/usePagedList'; import type { Feedback, FeedbackSummary } from '@/lib/types'; import FeedbackHandleDrawer from './FeedbackHandleDrawer'; @@ -431,7 +432,11 @@ export default function FeedbacksPage() { feedback={drawerFb} open={drawerOpen} onClose={() => setDrawerOpen(false)} - onDone={() => { reload(); loadSummary(); }} + onDone={() => { + reload(); + loadSummary(); + refreshReviewBadge('/feedbacks'); + }} /> diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 8d88cae..609375c 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { BarChartOutlined, @@ -25,7 +25,16 @@ import { import { Avatar, Dropdown, Layout, Tooltip } from 'antd'; import { api } from '@/lib/api'; import { clearAuth, getAdmin, getToken, setAuth } from '@/lib/auth'; -import type { AdminInfo } from '@/lib/types'; +import { + REVIEW_BADGE_REFRESH_EVENT, + type ReviewBadgeKey, +} from '@/lib/reviewBadge'; +import type { + AdminInfo, + FeedbackSummary, + PriceReportSummary, + WithdrawSummary, +} from '@/lib/types'; const { Sider, Header, Content } = Layout; @@ -96,6 +105,8 @@ export default function MainLayout({ children }: { children: React.ReactNode }) const pathname = usePathname(); const [admin, setAdmin] = useState(null); const [collapsed, setCollapsed] = useState(false); + const [pendingReviewCounts, setPendingReviewCounts] = useState>>({}); + const reviewRefreshVersion = useRef>>({}); useEffect(() => { const token = getToken(); @@ -114,6 +125,44 @@ export default function MainLayout({ children }: { children: React.ReactNode }) .catch(() => {}); }, [router]); + useEffect(() => { + if (!admin) return; + const canAccess = (page: string) => + admin.role === 'super_admin' || !admin.pages || admin.pages.includes(page); + + const refresh = async (key: ReviewBadgeKey) => { + if (!canAccess(key.slice(1))) return; + const version = (reviewRefreshVersion.current[key] ?? 0) + 1; + reviewRefreshVersion.current[key] = version; + let count: number; + if (key === '/withdraws') { + count = (await api.get('/admin/api/withdraws/summary')).data.reviewing_count; + } else if (key === '/price-reports') { + count = (await api.get('/admin/api/price-reports/summary')).data.pending; + } else { + count = (await api.get('/admin/api/feedbacks/summary')).data.pending; + } + if (reviewRefreshVersion.current[key] !== version) return; + setPendingReviewCounts((current) => ({ ...current, [key]: count })); + }; + + const refreshSafely = (key: ReviewBadgeKey) => refresh(key).catch(() => {}); + void Promise.all([ + refreshSafely('/withdraws'), + refreshSafely('/price-reports'), + refreshSafely('/feedbacks'), + ]); + + const onReviewCompleted = (event: Event) => { + const key = (event as CustomEvent).detail; + if (key) void refreshSafely(key); + }; + window.addEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted); + return () => { + window.removeEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted); + }; + }, [admin]); + if (!admin) return null; // 守卫期间不闪烁内容 // 选中态:取路径一级(/users/123 -> /users) @@ -132,6 +181,20 @@ export default function MainLayout({ children }: { children: React.ReactNode }) hasChildren(group) ? group.children : [group], ); + const reviewBadge = (key: string) => { + const count = pendingReviewCounts[key as ReviewBadgeKey] ?? 0; + if (count <= 0) return null; + return ( + + {count} + + ); + }; + const logout = () => { clearAuth(); router.replace('/login'); @@ -166,6 +229,7 @@ export default function MainLayout({ children }: { children: React.ReactNode }) aria-label={item.label} > {item.icon} + {reviewBadge(item.key)} )) @@ -183,7 +247,8 @@ export default function MainLayout({ children }: { children: React.ReactNode }) onClick={() => router.push(group.key)} > {group.icon} - {group.label} + {group.label} + {reviewBadge(group.key)} ); } @@ -202,7 +267,8 @@ export default function MainLayout({ children }: { children: React.ReactNode }) onClick={() => router.push(child.key)} > {child.icon} - {child.label} + {child.label} + {reviewBadge(child.key)} ))} @@ -269,6 +335,29 @@ export default function MainLayout({ children }: { children: React.ReactNode }) color: rgba(255, 255, 255, 0.72); font-size: 16px; } + .nav-item-label { + min-width: 0; + flex: 1; + } + .nav-review-badge { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + flex: 0 0 auto; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ff4d4f; + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 18px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); + } .nav-direct, .nav-child, .nav-icon-button { @@ -332,6 +421,7 @@ export default function MainLayout({ children }: { children: React.ReactNode }) padding: 0 8px 14px; } .nav-icon-button { + position: relative; display: inline-flex; align-items: center; justify-content: center; @@ -342,6 +432,11 @@ export default function MainLayout({ children }: { children: React.ReactNode }) color: rgba(255, 255, 255, 0.72); font-size: 17px; } + .nav-icon-button .nav-review-badge { + position: absolute; + top: 1px; + right: -6px; + } .nav-icon-button:hover, .nav-icon-button.is-selected { background: #1677ff; diff --git a/src/app/(main)/price-reports/page.tsx b/src/app/(main)/price-reports/page.tsx index 6f0e85e..d2eb62f 100644 --- a/src/app/(main)/price-reports/page.tsx +++ b/src/app/(main)/price-reports/page.tsx @@ -22,6 +22,7 @@ import dayjs from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { mediaUrl } from '@/lib/media'; +import { refreshReviewBadge } from '@/lib/reviewBadge'; import { usePagedList } from '@/lib/usePagedList'; import type { PriceReport, PriceReportSummary } from '@/lib/types'; import UserRecordsDrawer from '@/components/UserRecordsDrawer'; @@ -202,6 +203,7 @@ export default function PriceReportsPage() { onOk: async () => { try { await api.post(`/admin/api/price-reports/${r.id}/approve`); + refreshReviewBadge('/price-reports'); message.success('已通过,已发放 1000 金币'); refreshAfterChange(); } catch (e) { @@ -220,6 +222,7 @@ export default function PriceReportsPage() { } try { await api.post(`/admin/api/price-reports/${rejecting.id}/reject`, { reason }); + refreshReviewBadge('/price-reports'); message.success('已拒绝'); setRejecting(null); setRejectReason(''); diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index 66b46e9..666e757 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -39,6 +39,7 @@ import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatUtcTime, utcDayjs, utcFromNow, yuan } from '@/lib/format'; +import { refreshReviewBadge } from '@/lib/reviewBadge'; import { usePagedList } from '@/lib/usePagedList'; import type { AuditLog, @@ -332,6 +333,7 @@ export default function WithdrawsPage() { onOk: async () => { try { await api.post(`/admin/api/withdraws/${o.out_bill_no}/approve`); + refreshReviewBadge('/withdraws'); message.success('已通过审核并发起打款'); await refreshAfterChange(o.out_bill_no); } catch (e) { @@ -362,6 +364,7 @@ export default function WithdrawsPage() { await api.post(`/admin/api/withdraws/${targets[0].out_bill_no}/reject`, { reason: trimmed }); message.success('已拒绝并退回余额'); } + refreshReviewBadge('/withdraws'); setRejecting(null); setBulkRejecting([]); setRejectReason(''); @@ -400,6 +403,7 @@ export default function WithdrawsPage() { const { data } = await api.post('/admin/api/withdraws/bulk/approve', { out_bill_nos: selectedReviewing.map((item) => item.out_bill_no), }); + refreshReviewBadge('/withdraws'); const text = bulkResultText('批量通过完成', data); if (data.failed) message.warning(text); else message.success(text); diff --git a/src/lib/reviewBadge.ts b/src/lib/reviewBadge.ts new file mode 100644 index 0000000..78e95d4 --- /dev/null +++ b/src/lib/reviewBadge.ts @@ -0,0 +1,10 @@ +export type ReviewBadgeKey = '/withdraws' | '/price-reports' | '/feedbacks'; + +export const REVIEW_BADGE_REFRESH_EVENT = 'review-badge-refresh'; + +export function refreshReviewBadge(key: ReviewBadgeKey) { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(REVIEW_BADGE_REFRESH_EVENT, { detail: key }), + ); +}