Develop #21

Merged
sakibcc merged 5 commits from develop into main 2026-09-01 11:30:21 +08:00
4 changed files with 396 additions and 182 deletions
Showing only changes of commit be31235fc7 - Show all commits
@@ -1,12 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const useQuery = vi.hoisted(() => vi.fn())
const useInfiniteQuery = vi.hoisted(() => vi.fn())
const api = vi.hoisted(() => ({
getSectorRadarDates: vi.fn(),
getSectorRadarRankings: vi.fn(),
}))
vi.mock("@tanstack/react-query", () => ({ useQuery }))
vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery }))
vi.mock("./sector-radar.api", () => api)
import {
@@ -32,9 +33,10 @@ describe("sector radar query hooks", () => {
beforeEach(() => {
vi.clearAllMocks()
useQuery.mockImplementation((options) => options)
useInfiniteQuery.mockImplementation((options) => options)
})
it("keeps every server-affecting filter in the ranking query key", () => {
it("keeps filters in one ranking key without splitting cached pages", () => {
expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
expect(sectorRadarRankingsQueryKey(query)).toEqual([
"sectorRadar",
@@ -46,9 +48,11 @@ describe("sector radar query hooks", () => {
3,
"bottom",
"银行",
2,
50,
])
expect(
sectorRadarRankingsQueryKey({ ...query, page: query.page + 1 }),
).toEqual(sectorRadarRankingsQueryKey(query))
})
it("forwards React Query cancellation signals to both adapters", async () => {
@@ -57,15 +61,98 @@ describe("sector radar query hooks", () => {
const datesOptions = useQuery.mock.calls[0]?.[0] as {
queryFn: (context: { signal: AbortSignal }) => unknown
}
const rankingsOptions = useQuery.mock.calls[1]?.[0] as {
queryFn: (context: { signal: AbortSignal }) => unknown
const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as {
queryFn: (context: { pageParam: number; signal: AbortSignal }) => unknown
}
const signal = new AbortController().signal
await datesOptions.queryFn({ signal })
await rankingsOptions.queryFn({ signal })
await rankingsOptions.queryFn({ pageParam: 3, signal })
expect(api.getSectorRadarDates).toHaveBeenCalledWith(signal)
expect(api.getSectorRadarRankings).toHaveBeenCalledWith(query, signal)
expect(api.getSectorRadarRankings).toHaveBeenCalledWith(
{ ...query, page: 3 },
signal,
)
})
it("starts from page one and stops after loaded rows cover total", () => {
useSectorRadarRankings(query)
const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as {
getNextPageParam: (
lastPage: { page: number; rows: unknown[]; total: number },
pages: { page: number; rows: unknown[]; total: number }[],
lastPageParam: number,
) => number | undefined
initialPageParam: number
}
const firstPage = {
page: 1,
rows: Array(50).fill(null),
status: "success",
total: 75,
}
const secondPage = {
page: 2,
rows: Array(25).fill(null),
status: "success",
total: 75,
}
expect(rankingsOptions.initialPageParam).toBe(1)
expect(rankingsOptions.getNextPageParam(firstPage, [firstPage], 1)).toBe(2)
expect(
rankingsOptions.getNextPageParam(secondPage, [firstPage, secondPage], 2),
).toBeUndefined()
})
it("stops when a page is empty or does not match its requested page param", () => {
useSectorRadarRankings(query)
const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as {
getNextPageParam: (
lastPage: {
page: number
rows: unknown[]
status: "success" | "no_data"
total: number
},
pages: {
page: number
rows: unknown[]
status: "success" | "no_data"
total: number
}[],
lastPageParam: number,
) => number | undefined
}
const firstPage = {
page: 1,
rows: Array(50).fill(null),
status: "success" as const,
total: 75,
}
const emptyPage = {
page: 2,
rows: [],
status: "success" as const,
total: 75,
}
const mismatchedPage = {
page: 1,
rows: Array(25).fill(null),
status: "success" as const,
total: 75,
}
expect(
rankingsOptions.getNextPageParam(emptyPage, [firstPage, emptyPage], 2),
).toBeUndefined()
expect(
rankingsOptions.getNextPageParam(
mismatchedPage,
[firstPage, mismatchedPage],
2,
),
).toBeUndefined()
})
})
@@ -1,7 +1,14 @@
import { useQuery } from "@tanstack/react-query"
import {
useInfiniteQuery,
useQuery,
type InfiniteData,
} from "@tanstack/react-query"
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
import type { RadarRankingsQuery } from "./sector-radar.types"
import type {
RadarRankingsQuery,
RadarRankingsResponse,
} from "./sector-radar.types"
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
@@ -16,7 +23,6 @@ export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) =>
query.rankChangeDays,
query.side,
query.search ?? "",
query.page,
query.pageSize,
] as const
@@ -28,8 +34,33 @@ export function useSectorRadarDates() {
}
export function useSectorRadarRankings(query: RadarRankingsQuery) {
return useQuery({
queryFn: ({ signal }) => getSectorRadarRankings(query, signal),
return useInfiniteQuery<
RadarRankingsResponse,
Error,
InfiniteData<RadarRankingsResponse>,
ReturnType<typeof sectorRadarRankingsQueryKey>,
number
>({
getNextPageParam: (lastPage, pages, lastPageParam) => {
const loadedRowCount = pages.reduce(
(count, page) => count + page.rows.length,
0,
)
if (
lastPage.status !== "success" ||
lastPage.rows.length === 0 ||
lastPage.page !== lastPageParam ||
loadedRowCount >= lastPage.total
) {
return undefined
}
const nextPageParam = lastPageParam + 1
return Number.isSafeInteger(nextPageParam) ? nextPageParam : undefined
},
initialPageParam: 1,
queryFn: ({ pageParam, signal }) =>
getSectorRadarRankings({ ...query, page: pageParam }, signal),
queryKey: sectorRadarRankingsQueryKey(query),
})
}
@@ -14,6 +14,7 @@ const useSectorRadarDates = vi.fn()
const useSectorRadarRankings = vi.fn()
const refetchDates = vi.fn()
const refetchRankings = vi.fn()
const fetchNextPage = vi.fn()
let routeSearch: SectorRadarRouteSearch
@@ -114,6 +115,21 @@ const rankingsResponse: RadarRankingsResponse = {
],
}
function rankingQueryResult(overrides: Record<string, unknown> = {}) {
return {
data: { pageParams: [1], pages: [rankingsResponse] },
fetchNextPage,
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetching: false,
isFetchingNextPage: false,
isPending: false,
refetch: refetchRankings,
...overrides,
}
}
describe("SectorRadarPage", () => {
beforeEach(() => {
routeSearch = {
@@ -134,36 +150,43 @@ describe("SectorRadarPage", () => {
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
useSectorRadarRankings.mockReturnValue(rankingQueryResult())
navigate.mockReset()
refetchDates.mockReset()
refetchRankings.mockReset()
fetchNextPage.mockReset()
fetchNextPage.mockResolvedValue(undefined)
})
it("renders independent metric provenance, units, quality, and ranking rows", () => {
it("renders ranking rows without the description card or pagination", () => {
render(<SectorRadarPage />)
expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0)
expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument()
expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByText("样本有限")).toBeInTheDocument()
expect(screen.getByText("95%")).toBeInTheDocument()
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
expect(
screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"),
).not.toBeInTheDocument()
expect(
screen.queryByText("zhixing_amount_net_bn_v1"),
).not.toBeInTheDocument()
expect(
screen.queryByRole("button", { name: "第 1 页" }),
).not.toBeInTheDocument()
expect(
screen.queryByRole("combobox", { name: "每页数量" }),
).not.toBeInTheDocument()
})
it("stores search and pagination changes in router search state", () => {
it("resets filters to the first batch in router search state", () => {
routeSearch = { ...routeSearch, page: 3 }
render(<SectorRadarPage />)
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
target: { value: "机器人" },
})
fireEvent.click(screen.getByRole("button", { name: "第 1 页" }))
const searchCall = navigate.mock.calls[0]?.[0].search as (
previous: SectorRadarRouteSearch,
@@ -174,10 +197,99 @@ describe("SectorRadarPage", () => {
})
})
it("loads the next page once when repeated scroll events reach the bottom", () => {
fetchNextPage.mockReturnValue(new Promise(() => undefined))
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({ hasNextPage: true }),
)
render(<SectorRadarPage />)
const scrollRegion = screen.getByRole("region", {
name: "板块资金排名滚动区域",
})
Object.defineProperties(scrollRegion, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 1_000 },
scrollTop: { configurable: true, value: 550 },
})
fireEvent.scroll(scrollRegion)
fireEvent.scroll(scrollRegion)
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it("announces that the next page is loading without hiding loaded rows", () => {
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
hasNextPage: true,
isFetching: true,
isFetchingNextPage: true,
}),
)
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块")
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument()
})
it("merges rows from every loaded page", () => {
const firstPage = { ...rankingsResponse, total: 3 }
const nextPage = {
...rankingsResponse,
page: 2,
total: 3,
rows: [
{
...rankingsResponse.rows[0],
sector_code: "BK0003.DC",
sector_name: "商业航天",
rank_position: 3,
},
],
}
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
data: { pageParams: [1, 2], pages: [firstPage, nextPage] },
}),
)
render(<SectorRadarPage />)
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByText("低空经济")).toBeInTheDocument()
expect(screen.getByText("商业航天")).toBeInTheDocument()
})
it("keeps loaded rows and offers retry when loading more fails", () => {
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
}),
)
render(<SectorRadarPage />)
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByRole("alert")).toHaveTextContent(
"加载更多板块失败,已加载结果仍保留",
)
expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "重试加载更多" }))
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it("shows rank changes without inventing missing history", () => {
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
useSectorRadarRankings.mockReturnValue({
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
data: {
pageParams: [1],
pages: [
{
...rankingsResponse,
view: "rank_change",
rank_change_days: 5,
@@ -187,11 +299,10 @@ describe("SectorRadarPage", () => {
rank_change: index === 0 ? 3 : null,
})),
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
],
},
}),
)
render(<SectorRadarPage />)
@@ -230,13 +341,9 @@ describe("SectorRadarPage", () => {
})
it("keeps the last response visible while React Query refreshes it", () => {
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: false,
isFetching: true,
isPending: false,
refetch: refetchRankings,
})
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({ isFetching: true }),
)
render(<SectorRadarPage />)
@@ -247,13 +354,9 @@ describe("SectorRadarPage", () => {
})
it("keeps stale data visible when a background refresh fails", () => {
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({ isError: true }),
)
render(<SectorRadarPage />)
@@ -272,8 +375,12 @@ describe("SectorRadarPage", () => {
})
useSectorRadarRankings.mockReturnValue({
data: undefined,
fetchNextPage,
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetching: true,
isFetchingNextPage: false,
isPending: true,
refetch: refetchRankings,
})
@@ -289,8 +396,12 @@ describe("SectorRadarPage", () => {
})
useSectorRadarRankings.mockReturnValue({
data: undefined,
fetchNextPage,
hasNextPage: false,
isError: true,
isFetchNextPageError: false,
isFetching: false,
isFetchingNextPage: false,
isPending: false,
refetch: refetchRankings,
})
@@ -311,19 +422,22 @@ describe("SectorRadarPage", () => {
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
data: {
pageParams: [1],
pages: [
{
...rankingsResponse,
status: "no_data",
publication: null,
rows: [],
total: 0,
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
],
},
}),
)
rerender(<SectorRadarPage />)
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
})
@@ -1,5 +1,5 @@
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
import { useEffect } from "react"
import { useRef } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { PageLayout } from "@/app/layout/page-layout"
@@ -13,6 +13,7 @@ import type {
RadarPublication,
RadarRankingRow,
RadarRankingsQuery,
RadarRankingsResponse,
SectorRadarRouteSearch,
} from "@/features/sector-radar/api/sector-radar.types"
import { Badge } from "@/shared/ui/badge"
@@ -25,7 +26,6 @@ import {
CardTitle,
} from "@/shared/ui/card"
import { Input } from "@/shared/ui/input"
import { Pagination } from "@/shared/ui/pagination"
import {
Select,
SelectContent,
@@ -36,8 +36,6 @@ import {
} from "@/shared/ui/select"
import { Skeleton } from "@/shared/ui/skeleton"
const PAGE_SIZE_OPTIONS = [10, 20, 50] as const
const sectorTypeOptions = [
{ label: "概念板块", value: "concept" },
{ label: "行业板块", value: "industry" },
@@ -83,16 +81,9 @@ export function SectorRadarPage() {
}
const dates = useSectorRadarDates()
const rankings = useSectorRadarRankings(query)
const total = rankings.data?.total ?? 0
const pageCount = Math.max(1, Math.ceil(total / search.pageSize))
useEffect(() => {
if (rankings.data?.status === "success" && search.page > pageCount) {
void navigate({
search: (previous) => ({ ...previous, page: pageCount }),
})
}
}, [navigate, pageCount, rankings.data?.status, search.page])
const rankingPages = rankings.data?.pages ?? []
const firstRankingPage = rankingPages[0]
const rankingRows = rankingPages.flatMap((page) => page.rows)
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
void navigate({ search: (previous) => ({ ...previous, ...next }) })
@@ -100,13 +91,17 @@ export function SectorRadarPage() {
const loading = dates.isPending || rankings.isPending
const refreshing =
!loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching))
!loading &&
(Boolean(dates.isFetching) ||
(Boolean(rankings.isFetching) && !rankings.isFetchingNextPage))
const fatalError =
(dates.isError && !dates.data) || (rankings.isError && !rankings.data)
const refreshFailed =
!fatalError && (Boolean(dates.isError) || Boolean(rankings.isError))
!fatalError &&
(Boolean(dates.isError) ||
(Boolean(rankings.isError) && !rankings.isFetchNextPageError))
const noData =
dates.data?.status === "no_data" || rankings.data?.status === "no_data"
dates.data?.status === "no_data" || firstRankingPage?.status === "no_data"
return (
<PageLayout
@@ -134,29 +129,26 @@ export function SectorRadarPage() {
!fatalError &&
!noData &&
dates.data &&
rankings.data?.status === "success" &&
rankings.data.publication ? (
firstRankingPage?.status === "success" &&
firstRankingPage.publication ? (
<>
{refreshFailed ? (
<RadarRefreshFailureStatus />
) : refreshing ? (
<RadarRefreshStatus />
) : null}
<RadarStatusSummary
<RadarPublicationStatus
currentAttempt={dates.data.current_attempt}
lastGood={dates.data.last_good}
publication={rankings.data.publication}
disclaimer={rankings.data.definition.disclaimer}
metricVersion={rankings.data.definition.metric_version}
publication={firstRankingPage.publication}
/>
<RadarTable
onPageChange={(page) => updateSearch({ page })}
onPageSizeChange={(pageSize) =>
updateSearch({ page: 1, pageSize })
}
page={Math.min(search.page, pageCount)}
pageSize={search.pageSize}
response={rankings.data}
hasNextPage={Boolean(rankings.hasNextPage)}
isFetchNextPageError={rankings.isFetchNextPageError}
isFetchingNextPage={rankings.isFetchingNextPage}
onLoadMore={() => rankings.fetchNextPage()}
response={firstRankingPage}
rows={rankingRows}
/>
</>
) : null}
@@ -307,17 +299,13 @@ function FilterSelect({
)
}
function RadarStatusSummary({
function RadarPublicationStatus({
currentAttempt,
disclaimer,
lastGood,
metricVersion,
publication,
}: {
currentAttempt: RadarPublication | null
disclaimer: string
lastGood: RadarPublication | null
metricVersion: string
publication: RadarPublication
}) {
const degradedAttempt =
@@ -325,80 +313,71 @@ function RadarStatusSummary({
currentAttempt.status !== "success" &&
currentAttempt.publication_id !== lastGood?.publication_id
if (!degradedAttempt) return null
return (
<Card className="shrink-0 shadow-none">
<CardHeader className="gap-3 p-4 pb-3">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">收盘后板块资金排名</CardTitle>
<CardDescription className="mt-1">{disclaimer}</CardDescription>
</div>
<Badge variant="outline">知行独立实现</Badge>
<Badge variant="outline">{metricVersion}</Badge>
</div>
{degradedAttempt ? (
<div
role="status"
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
className="shrink-0 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
>
{attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
{currentAttempt.target_trade_date}
,当前仍展示最近有效发布{" "}
{currentAttempt.target_trade_date},当前仍展示最近有效发布{" "}
{lastGood?.target_trade_date ?? publication.target_trade_date}。
</div>
) : null}
</CardHeader>
<CardContent className="grid gap-3 p-4 pt-0 text-xs sm:grid-cols-2 lg:grid-cols-5">
<SummaryItem label="数据交易日" value={publication.target_trade_date} />
<SummaryItem
label="发布完成"
value={formatDateTime(publication.finished_at)}
/>
<SummaryItem
label="全局覆盖率"
value={formatCoverage(publication.coverage)}
/>
<SummaryItem label="事实来源" value={publication.source_version} />
<SummaryItem
label="板块范围版本"
value={publication.universe_version}
/>
</CardContent>
</Card>
)
}
function SummaryItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-muted-foreground">{label}</p>
<p className="mt-1 break-all font-medium text-foreground tabular-nums">
{value}
</p>
</div>
)
}
function RadarTable({
onPageChange,
onPageSizeChange,
page,
pageSize,
hasNextPage,
isFetchNextPageError,
isFetchingNextPage,
onLoadMore,
response,
rows,
}: {
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
page: number
pageSize: number
response: Exclude<
ReturnType<typeof useSectorRadarRankings>["data"],
undefined
>
hasNextPage: boolean
isFetchNextPageError: boolean
isFetchingNextPage: boolean
onLoadMore: () => Promise<unknown>
response: RadarRankingsResponse
rows: RadarRankingRow[]
}) {
const rankChangeView = response.view === "rank_change"
const loadMoreRequestPending = useRef(false)
function requestLoadMore() {
if (loadMoreRequestPending.current || isFetchingNextPage) return
loadMoreRequestPending.current = true
const releaseRequest = () => {
loadMoreRequestPending.current = false
}
void onLoadMore().then(releaseRequest, releaseRequest)
}
return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
<div
aria-label="板块资金排名滚动区域"
className="min-h-0 min-w-0 flex-1 overflow-auto"
onScroll={(event) => {
const container = event.currentTarget
const remaining =
container.scrollHeight -
container.scrollTop -
container.clientHeight
if (
remaining <= 160 &&
hasNextPage &&
!isFetchingNextPage &&
!isFetchNextPageError
) {
requestLoadMore()
}
}}
role="region"
tabIndex={0}
>
<table className="w-full min-w-[920px] border-collapse text-sm">
<caption className="sr-only">
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
@@ -436,14 +415,14 @@ function RadarTable({
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{response.rows.map((row) => (
{rows.map((row) => (
<RadarTableRow
key={`${row.sector_type}-${row.sector_code}`}
rankChangeView={rankChangeView}
row={row}
/>
))}
{response.rows.length === 0 ? (
{rows.length === 0 ? (
<tr>
<td
className="px-3 py-12 text-center text-muted-foreground"
@@ -455,15 +434,27 @@ function RadarTable({
) : null}
</tbody>
</table>
{isFetchingNextPage ? (
<div
aria-live="polite"
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
role="status"
>
正在加载更多板块…
</div>
) : null}
{isFetchNextPageError ? (
<div
className="flex items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
role="alert"
>
<span>加载更多板块失败,已加载结果仍保留。</span>
<Button onClick={requestLoadMore} size="sm" variant="outline">
重试加载更多
</Button>
</div>
) : null}
</div>
<Pagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
page={page}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={response.total}
/>
</section>
)
}
@@ -631,15 +622,6 @@ function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString("zh-CN", { maximumFractionDigits })
}
function formatDateTime(value: string | null) {
if (!value) return "—"
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Shanghai",
}).format(new Date(value))
}
function attemptStatusLabel(status: RadarPublication["status"]) {
if (status === "running") return "新一期仍在构建"
if (status === "partial") return "新一期数据不完整"