Add admin truth history dashboard and training data ops views
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/truth-history`);
|
||||
for (const key of ["city", "date_from", "date_to", "limit"]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
if (value) url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch truth history", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TruthHistoryDashboard } from "@/components/ops/TruthHistoryDashboard";
|
||||
import { requireOpsAdmin } from "@/lib/ops-admin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather Truth History",
|
||||
description: "Admin truth history viewer for PolyWeather.",
|
||||
};
|
||||
|
||||
export default async function TruthHistoryPage() {
|
||||
await requireOpsAdmin("/ops/truth-history");
|
||||
return <TruthHistoryDashboard />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AlertTriangle, Database, RefreshCcw, ShieldCheck, Wallet } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -558,10 +559,60 @@ export function OpsDashboard() {
|
||||
}
|
||||
}, [grantEmail, grantPoints, incidentReasonFilter, loadLeaderboard, loadMemberships, loadPaymentIncidents, loadUsers, searchQuery]);
|
||||
|
||||
const opsNav = [
|
||||
{ id: "overview", label: "总览" },
|
||||
{ id: "funnel", label: "转化漏斗" },
|
||||
{ id: "probability", label: "EMOS 门禁" },
|
||||
{ id: "training-data", label: "训练数据" },
|
||||
{ id: "coverage", label: "覆盖状态" },
|
||||
{ id: "payments", label: "支付异常" },
|
||||
{ id: "members", label: "会员" },
|
||||
{ id: "users", label: "用户与积分" },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-950 px-3 py-6 text-slate-100 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5 sm:gap-6">
|
||||
<section className="flex flex-col gap-4 rounded-2xl border border-slate-800 bg-slate-900/80 p-4 shadow-2xl backdrop-blur-xl sm:rounded-3xl sm:p-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="mx-auto grid max-w-7xl gap-5 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-6">
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-6 space-y-4 rounded-3xl border border-slate-800 bg-slate-900/80 p-4 shadow-2xl backdrop-blur-xl">
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-[0.2em] text-cyan-300">Ops Console</div>
|
||||
<div className="mt-2 text-lg font-black text-slate-100">PolyWeather Admin</div>
|
||||
<div className="mt-1 text-xs text-slate-500">后台化视图:先看总览,再看数据、模型、支付与会员。</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{opsNav.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className="flex items-center justify-between rounded-2xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-300 transition hover:border-cyan-400/40 hover:text-white"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span className="text-[11px] text-slate-500">#{item.id}</span>
|
||||
</a>
|
||||
))}
|
||||
<Link
|
||||
href="/ops/truth-history"
|
||||
className="flex items-center justify-between rounded-2xl border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-sm text-cyan-100 transition hover:border-cyan-400/60 hover:bg-cyan-500/15 hover:text-white"
|
||||
>
|
||||
<span>真值历史表</span>
|
||||
<span className="text-[11px] text-cyan-200/80">/ops/truth-history</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2">
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Health</div>
|
||||
<div className="mt-1 text-sm text-slate-200">{health?.status || "unknown"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2">
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">EMOS</div>
|
||||
<div className="mt-1 text-sm text-slate-200">{rolloutDecision?.decision || "hold"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-col gap-5 sm:gap-6">
|
||||
<section id="overview" className="flex flex-col gap-4 rounded-2xl border border-slate-800 bg-slate-900/80 p-4 shadow-2xl backdrop-blur-xl sm:rounded-3xl sm:p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="secondary">Ops</Badge>
|
||||
@@ -578,7 +629,7 @@ export function OpsDashboard() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight sm:text-3xl">PolyWeather Ops</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-slate-400">
|
||||
直接挂在现有域名下的轻量运营页。先做只读运维视图,把系统状态、支付运行态和当前登录态聚合起来。
|
||||
用后台管理系统的方式组织运营视图,把系统状态、训练数据、模型覆盖、支付与会员放到一页里。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -589,6 +640,24 @@ export function OpsDashboard() {
|
||||
{loading ? "加载中" : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
|
||||
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">Health</div>
|
||||
<div className="mt-2 text-2xl font-black text-slate-100">{health?.status || "unknown"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
|
||||
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">Truth Rows</div>
|
||||
<div className="mt-2 text-2xl font-black text-slate-100">{truthRecords?.row_count ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
|
||||
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">EMOS Samples</div>
|
||||
<div className="mt-2 text-2xl font-black text-slate-100">{trainingArtifacts?.emos_training_samples ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
|
||||
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">LGBM Samples</div>
|
||||
<div className="mt-2 text-2xl font-black text-slate-100">{trainingArtifacts?.lgbm_sample_count ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
@@ -662,7 +731,7 @@ export function OpsDashboard() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<section id="funnel" className="grid gap-4 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>转化漏斗</CardTitle>
|
||||
@@ -738,7 +807,7 @@ export function OpsDashboard() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
|
||||
<section id="probability" className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>EMOS 上线门禁</CardTitle>
|
||||
@@ -812,13 +881,22 @@ export function OpsDashboard() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1.2fr_1.2fr_1fr]">
|
||||
<section id="training-data" className="grid gap-4 xl:grid-cols-[1.2fr_1.2fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>历史真值主表</CardTitle>
|
||||
<CardDescription>永久监督真值,不再受 14 天运行态缓存裁剪影响。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-300">
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-cyan-500/20 bg-cyan-500/5 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-[0.16em] text-cyan-300">Truth Viewer</div>
|
||||
<div className="mt-1 text-xs text-slate-400">按城市和日期直接查看历史真值、来源站点与更新记录。</div>
|
||||
</div>
|
||||
<Button asChild variant="secondary" className="shrink-0">
|
||||
<Link href="/ops/truth-history">打开表格</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<MobileField label="truth rows" value={String(truthRecords?.row_count ?? 0)} mono />
|
||||
<MobileField label="cities" value={String(truthRecords?.cities_count ?? 0)} mono />
|
||||
@@ -902,7 +980,7 @@ export function OpsDashboard() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
|
||||
<section id="coverage" className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wunderground 回填观察</CardTitle>
|
||||
@@ -1038,7 +1116,7 @@ export function OpsDashboard() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<Card id="payments">
|
||||
<CardHeader>
|
||||
<CardTitle>城市覆盖矩阵</CardTitle>
|
||||
<CardDescription>把 truth / feature / EMOS / LGBM 放到一张表里,快速判断哪些城市还只能靠 DEB。</CardDescription>
|
||||
@@ -1113,7 +1191,7 @@ export function OpsDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card id="members">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
@@ -1287,7 +1365,7 @@ export function OpsDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<section id="users" className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户查询</CardTitle>
|
||||
@@ -1381,6 +1459,7 @@ export function OpsDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
type TruthHistoryItem = {
|
||||
city: string;
|
||||
display_name?: string;
|
||||
target_date: string;
|
||||
actual_high?: number | null;
|
||||
settlement_source?: string | null;
|
||||
settlement_station_code?: string | null;
|
||||
settlement_station_label?: string | null;
|
||||
truth_version?: string | null;
|
||||
updated_by?: string | null;
|
||||
truth_updated_at?: number | null;
|
||||
is_final?: boolean | null;
|
||||
};
|
||||
|
||||
type TruthHistoryPayload = {
|
||||
items?: TruthHistoryItem[];
|
||||
available_cities?: Array<{ city: string; name?: string }>;
|
||||
filters?: {
|
||||
city?: string | null;
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
limit?: number;
|
||||
};
|
||||
filtered_count?: number;
|
||||
};
|
||||
|
||||
function formatUnixDateTime(value?: number | null) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value * 1000);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
async function readJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
const raw = await response.text();
|
||||
throw new Error(`${url} -> HTTP ${response.status} ${raw.slice(0, 180)}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function TruthHistoryDashboard() {
|
||||
const [city, setCity] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [limit, setLimit] = useState("200");
|
||||
const [payload, setPayload] = useState<TruthHistoryPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = new URL("/api/ops/truth-history", window.location.origin);
|
||||
if (city.trim()) url.searchParams.set("city", city.trim());
|
||||
if (dateFrom.trim()) url.searchParams.set("date_from", dateFrom.trim());
|
||||
if (dateTo.trim()) url.searchParams.set("date_to", dateTo.trim());
|
||||
if (limit.trim()) url.searchParams.set("limit", limit.trim());
|
||||
const data = await readJson<TruthHistoryPayload>(url.toString());
|
||||
setPayload(data);
|
||||
} catch (loadError) {
|
||||
setError(String(loadError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [city, dateFrom, dateTo, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const items = payload?.items || [];
|
||||
const availableCities = payload?.available_cities || [];
|
||||
const stats = useMemo(() => {
|
||||
const uniqueCities = new Set(items.map((item) => item.city)).size;
|
||||
const finalCount = items.filter((item) => item.is_final).length;
|
||||
return {
|
||||
rows: items.length,
|
||||
filtered: payload?.filtered_count ?? items.length,
|
||||
uniqueCities,
|
||||
finalCount,
|
||||
};
|
||||
}, [items, payload?.filtered_count]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-950 px-3 py-6 text-slate-100 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5 sm:gap-6">
|
||||
<section className="rounded-3xl border border-slate-800 bg-slate-900/80 p-4 shadow-2xl backdrop-blur-xl sm:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="secondary">Ops</Badge>
|
||||
<Badge variant="secondary">Truth History</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight sm:text-3xl">真值历史浏览</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-slate-400">
|
||||
面向后台运营/研究的历史真值表格页,支持按城市和日期范围过滤,直接查看最终 `actual_high` 与来源口径。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => void load()} disabled={loading} className="gap-2">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{loading ? "加载中" : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>当前结果</CardTitle>
|
||||
<CardDescription>本次筛选实际返回的记录条数。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-black text-slate-100">{stats.rows}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>匹配总数</CardTitle>
|
||||
<CardDescription>过滤后总命中条数,返回结果受 limit 限制。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-black text-slate-100">{stats.filtered}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>覆盖城市</CardTitle>
|
||||
<CardDescription>本次结果涉及的城市数量。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-black text-slate-100">{stats.uniqueCities}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Final Rows</CardTitle>
|
||||
<CardDescription>当前返回里标记为最终真值的条数。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-black text-slate-100">{stats.finalCount}</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>筛选器</CardTitle>
|
||||
<CardDescription>按 city / date range 查历史真值。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 lg:grid-cols-[1.4fr_1fr_1fr_160px_auto]">
|
||||
<select
|
||||
value={city}
|
||||
onChange={(event) => setCity(event.target.value)}
|
||||
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
|
||||
>
|
||||
<option value="">全部城市</option>
|
||||
{availableCities.map((item) => (
|
||||
<option key={item.city} value={item.city}>
|
||||
{item.name || item.city}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(event) => setDateFrom(event.target.value)}
|
||||
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(event) => setDateTo(event.target.value)}
|
||||
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(event.target.value)}
|
||||
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
|
||||
/>
|
||||
<Button onClick={() => void load()} disabled={loading}>
|
||||
应用筛选
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-rose-500/30 bg-rose-500/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-rose-300">加载失败</CardTitle>
|
||||
<CardDescription className="text-rose-200/80">{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>历史真值表</CardTitle>
|
||||
<CardDescription>
|
||||
字段包括 `actual_high`、`settlement_source`、`station_code`、`truth_version`、`updated_by`、`updated_at`。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70">
|
||||
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
||||
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.14em] text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Date</th>
|
||||
<th className="px-4 py-3">City</th>
|
||||
<th className="px-4 py-3">Actual</th>
|
||||
<th className="px-4 py-3">Source</th>
|
||||
<th className="px-4 py-3">Station</th>
|
||||
<th className="px-4 py-3">Version</th>
|
||||
<th className="px-4 py-3">Updated By</th>
|
||||
<th className="px-4 py-3">Updated At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{items.map((item) => (
|
||||
<tr key={`${item.city}-${item.target_date}`}>
|
||||
<td className="px-4 py-3">{item.target_date}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-semibold text-slate-100">{item.display_name || item.city}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{item.city}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-semibold text-slate-100">{item.actual_high ?? "-"}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{item.is_final ? "final" : "non-final"}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{item.settlement_source || "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div>{item.settlement_station_code || "-"}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{item.settlement_station_label || "-"}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{item.truth_version || "-"}</td>
|
||||
<td className="px-4 py-3">{item.updated_by || "-"}</td>
|
||||
<td className="px-4 py-3">{formatUnixDateTime(item.truth_updated_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length ? (
|
||||
<tr>
|
||||
<td className="px-4 py-4 text-slate-500" colSpan={8}>
|
||||
当前筛选条件下没有历史真值记录
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user