feat(report): improve daily intelligence UX and price tracking
Some checks failed
CI / go-test (push) Has been cancelled
CI / scripts-regression (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / docker-build (push) Has been cancelled

This commit is contained in:
phamnazage-jpg
2026-05-27 17:23:08 +08:00
parent f274621013
commit f5b373caf4
29 changed files with 4257 additions and 801 deletions

View File

@@ -3,7 +3,8 @@
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.navbar {
@@ -252,7 +253,11 @@
border: 1px solid #dbeafe;
border-radius: 18px;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.10), transparent 35%),
radial-gradient(
circle at top left,
rgba(37, 99, 235, 0.1),
transparent 35%
),
rgba(255, 255, 255, 0.94);
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.08);
}
@@ -343,6 +348,44 @@
color: #6b7280;
font-size: 13px;
}
.runtime-warning {
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid #f59e0b;
border-radius: 10px;
background: #fff7ed;
color: #9a3412;
font-size: 14px;
line-height: 1.5;
}
.runtime-error {
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid #dc2626;
border-radius: 10px;
background: #fef2f2;
color: #991b1b;
font-size: 14px;
line-height: 1.5;
}
.runtime-error-inline {
color: #991b1b;
}
.data-empty {
padding: 18px;
border: 1px dashed #d1d5db;
border-radius: 10px;
background: #f9fafb;
color: #6b7280;
text-align: center;
}
.runtime-warning-inline {
color: #9a3412;
}
.subscription-section {
padding: 20px;
@@ -431,6 +474,293 @@
text-align: center;
}
.explorer-editorial,
.report-priority-section {
display: grid;
gap: 18px;
}
.explorer-hero,
.filters-editorial,
.pricing-focus-card,
.pricing-board-card,
.report-priority-card,
.report-news-card,
.report-theme-card {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.96) 100%
);
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.06);
}
.explorer-hero,
.pricing-focus-card,
.report-priority-card {
padding: 22px;
}
.explorer-kicker,
.report-news-label {
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #475569;
}
.explorer-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.explorer-hero h2 {
margin: 6px 0 8px;
font-size: 34px;
line-height: 1.05;
color: #0f172a;
}
.explorer-hero p,
.pricing-focus-header p,
.report-news-summary,
.report-theme-card li,
.report-evidence {
color: #475569;
line-height: 1.6;
}
.explorer-hero-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.explorer-hero-meta span,
.report-news-label {
padding: 8px 12px;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
}
.filters-editorial {
padding: 14px 16px;
background: #fff;
}
.pricing-focus-header,
.report-news-grid,
.report-theme-grid,
.pricing-board-grid {
display: grid;
gap: 14px;
}
.pricing-focus-header {
grid-template-columns: 1fr auto;
align-items: start;
}
.pricing-focus-header h3,
.report-theme-title,
.report-news-title {
margin: 8px 0 6px;
color: #0f172a;
}
.pricing-focus-prices {
margin-top: 18px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.pricing-focus-prices div,
.pricing-board-card,
.report-news-card,
.report-theme-card {
padding: 16px;
}
.pricing-focus-prices span,
.pricing-board-meta {
display: block;
color: #64748b;
font-size: 13px;
}
.pricing-focus-prices strong,
.pricing-board-price {
display: block;
margin-top: 8px;
font-size: 24px;
color: #111827;
}
.pricing-board-grid,
.report-news-grid,
.report-theme-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.report-priority-card {
background: linear-gradient(180deg, #fff 0%, #f8fafc 100%);
}
.report-hero-priority {
background: linear-gradient(135deg, #0f172a 0%, #1e3a8a 55%, #2563eb 100%);
}
.report-evidence {
margin-top: 12px;
color: rgba(255, 255, 255, 0.82);
}
.report-theme-card ul {
margin: 12px 0 0;
padding-left: 18px;
}
.model-table-editorial {
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.04);
}
.theme-news-list {
display: grid;
gap: 12px;
margin-top: 12px;
}
.theme-news-item {
padding: 14px;
border-radius: 14px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(148, 163, 184, 0.18);
border-left: 6px solid #94a3b8;
}
.theme-news-item.tone-success {
background: linear-gradient(180deg, rgba(240, 253, 244, 0.98) 0%, rgba(220, 252, 231, 0.92) 100%);
border-color: rgba(34, 197, 94, 0.28);
border-left-color: #16a34a;
}
.theme-news-item.tone-success .card-title,
.theme-news-item.tone-success .trust-line {
color: #166534;
}
.theme-news-item.tone-caution {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98) 0%, rgba(254, 226, 226, 0.92) 100%);
border-color: rgba(239, 68, 68, 0.26);
border-left-color: #dc2626;
}
.theme-news-item.tone-caution .card-title,
.theme-news-item.tone-caution .trust-line {
color: #991b1b;
}
.theme-news-item.tone-promo {
background: linear-gradient(180deg, rgba(255, 247, 237, 0.98) 0%, rgba(250, 245, 255, 0.94) 100%);
border-color: rgba(168, 85, 247, 0.22);
border-left-color: #f97316;
}
.theme-news-item.tone-promo .card-title,
.theme-news-item.tone-promo .trust-line {
color: #9a3412;
}
.report-theme-card {
background: linear-gradient(180deg, rgba(248, 250, 252, 0.98) 0%, rgba(241, 245, 249, 0.94) 100%);
}
.report-theme-card:first-child {
border-color: rgba(34, 197, 94, 0.18);
}
.report-theme-card:first-child .report-theme-title {
color: #166534;
}
.report-theme-card:nth-child(2) {
border-color: rgba(239, 68, 68, 0.18);
}
.report-theme-card:nth-child(2) .report-theme-title {
color: #991b1b;
}
.report-theme-card:nth-child(3) {
border-color: rgba(249, 115, 22, 0.2);
}
.report-theme-card:nth-child(3) .report-theme-title {
color: #9a3412;
}
.report-theme-badge {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.report-theme-badge-icon {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(15, 23, 42, 0.08);
font-weight: 700;
}
.report-theme-badge-label {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #475569;
}
.report-theme-card.tone-success .report-theme-badge-icon {
background: rgba(34, 197, 94, 0.16);
color: #166534;
}
.report-theme-card.tone-caution .report-theme-badge-icon {
background: rgba(239, 68, 68, 0.16);
color: #991b1b;
}
.report-theme-card.tone-promo .report-theme-badge-icon {
background: rgba(249, 115, 22, 0.16);
color: #9a3412;
}
@media (max-width: 768px) {
.explorer-hero,
.pricing-focus-header {
grid-template-columns: 1fr;
display: grid;
}
.explorer-hero h2 {
font-size: 28px;
}
.pricing-focus-prices {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.app {
padding: 16px;

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import {
formatPrice,
formatSubscriptionQuota,
@@ -7,113 +7,207 @@ import {
providerDistribution,
summarizeModels,
summarizeSubscriptionPlans,
} from './models'
} from "./models";
describe('models helpers', () => {
it('normalizes fallback pricing and stale flags', () => {
describe("models helpers", () => {
it("normalizes fallback pricing and stale flags", () => {
const model = normalizeModel({
id: 'anthropic/claude-sonnet-4.6',
provider_cn: 'Anthropic',
id: "anthropic/claude-sonnet-4.6",
provider_cn: "Anthropic",
context_length: 200000,
input_price: '3',
output_price: '15',
data_confidence: 'stale',
})
input_price: "3",
output_price: "15",
data_confidence: "stale",
});
expect(model).not.toBeNull()
expect(model?.providerCN).toBe('Anthropic')
expect(model?.inputPrice).toBe(3)
expect(model?.outputPrice).toBe(15)
expect(model?.stale).toBe(true)
expect(model?.pricingAvailable).toBe(true)
})
expect(model).not.toBeNull();
expect(model?.providerCN).toBe("Anthropic");
expect(model?.inputPrice).toBe(3);
expect(model?.outputPrice).toBe(15);
expect(model?.stale).toBe(true);
expect(model?.pricingAvailable).toBe(true);
});
it('marks free models and pricing unavailable correctly', () => {
it("marks free models and pricing unavailable correctly", () => {
const freeModel = normalizeModel({
id: 'qwen/qwen3-coder:free',
})
id: "qwen/qwen3-coder:free",
});
const paidModel = normalizeModel({
id: 'openai/gpt-4.1',
id: "openai/gpt-4.1",
pricing: {},
})
});
expect(formatPrice(freeModel!, 'input')).toContain('免费')
expect(formatPrice(paidModel!, 'input')).toBe('pricing unavailable')
})
expect(formatPrice(freeModel!, "input")).toContain("免费");
expect(formatPrice(paidModel!, "input")).toBe("pricing unavailable");
});
it('summarizes providers and currencies', () => {
it("summarizes providers and currencies", () => {
const models = [
normalizeModel({ id: 'deepseek/deepseek-chat', provider_cn: 'DeepSeek', currency: 'CNY', pricing: { input: 1, output: 2 } }),
normalizeModel({ id: 'deepseek/deepseek-reasoner', provider_cn: 'DeepSeek', currency: 'CNY', pricing: { input: 2, output: 4 } }),
normalizeModel({ id: 'anthropic/claude-sonnet-4.6', provider_cn: 'Anthropic', currency: 'USD', pricing: { input: 3, output: 15 } }),
].filter((model): model is NonNullable<typeof model> => model !== null)
normalizeModel({
id: "deepseek/deepseek-chat",
provider_cn: "DeepSeek",
currency: "CNY",
pricing: { input: 1, output: 2 },
}),
normalizeModel({
id: "deepseek/deepseek-reasoner",
provider_cn: "DeepSeek",
currency: "CNY",
pricing: { input: 2, output: 4 },
}),
normalizeModel({
id: "anthropic/claude-sonnet-4.6",
provider_cn: "Anthropic",
currency: "USD",
pricing: { input: 3, output: 15 },
}),
].filter((model): model is NonNullable<typeof model> => model !== null);
expect(summarizeModels(models)).toEqual({
modelCount: 3,
providerCount: 2,
cnyCount: 2,
})
});
expect(providerDistribution(models)).toEqual([
{ name: 'DeepSeek', value: 2 },
{ name: 'Anthropic', value: 1 },
])
})
{ name: "DeepSeek", value: 2 },
{ name: "Anthropic", value: 1 },
]);
});
it('normalizes subscription plans from API payload', () => {
it("normalizes subscription plans from API payload", () => {
const plan = normalizeSubscriptionPlan({
planCode: 'token-plan-lite',
planName: '通用 Token Plan Lite',
planFamily: 'token_plan',
tier: 'Lite',
provider: 'Tencent',
providerCN: '腾讯',
operator: 'Tencent Cloud',
operatorCN: '腾讯云',
currency: 'CNY',
planCode: "token-plan-lite",
planName: "通用 Token Plan Lite",
planFamily: "token_plan",
tier: "Lite",
provider: "Tencent",
providerCN: "腾讯",
operator: "Tencent Cloud",
operatorCN: "腾讯云",
currency: "CNY",
listPrice: 39,
priceUnit: 'CNY/month',
priceUnit: "CNY/month",
quotaValue: 35000000,
quotaUnit: 'tokens/month',
quotaUnit: "tokens/month",
contextWindow: 0,
modelScope: ['tc-code-latest', 'glm-5', 'glm-5.1'],
})
modelScope: ["tc-code-latest", "glm-5", "glm-5.1"],
});
expect(plan).not.toBeNull()
expect(plan?.planCode).toBe('token-plan-lite')
expect(plan?.providerCN).toBe('腾讯')
expect(plan?.modelScope.length).toBe(3)
expect(plan?.modelPreview).toBe('tc-code-latest, glm-5, glm-5.1')
})
expect(plan).not.toBeNull();
expect(plan?.planCode).toBe("token-plan-lite");
expect(plan?.providerCN).toBe("腾讯");
expect(plan?.modelScope.length).toBe(3);
expect(plan?.modelPreview).toBe("tc-code-latest, glm-5, glm-5.1");
});
it('formats subscription quotas and summarizes plan stats', () => {
it("formats subscription quotas and summarizes plan stats", () => {
const plans = [
normalizeSubscriptionPlan({
planCode: 'token-plan-lite',
planName: '通用 Token Plan Lite',
providerCN: '腾讯',
planCode: "token-plan-lite",
planName: "通用 Token Plan Lite",
providerCN: "腾讯",
listPrice: 39,
quotaValue: 35000000,
quotaUnit: 'tokens/month',
modelScope: ['tc-code-latest', 'glm-5', 'glm-5.1'],
quotaUnit: "tokens/month",
modelScope: ["tc-code-latest", "glm-5", "glm-5.1"],
}),
normalizeSubscriptionPlan({
planCode: 'hy-token-plan-max',
planName: 'Hy Token Plan Max',
providerCN: '腾讯',
planCode: "hy-token-plan-max",
planName: "Hy Token Plan Max",
providerCN: "腾讯",
listPrice: 468,
quotaValue: 650000000,
quotaUnit: 'tokens/month',
quotaUnit: "tokens/month",
contextWindow: 262144,
modelScope: ['hy3-preview'],
modelScope: ["hy3-preview"],
}),
].filter((plan): plan is NonNullable<typeof plan> => plan !== null)
].filter((plan): plan is NonNullable<typeof plan> => plan !== null);
expect(formatSubscriptionQuota(plans[0].quotaValue, plans[0].quotaUnit)).toBe('3500万 Tokens/月')
expect(formatSubscriptionQuota(plans[1].quotaValue, plans[1].quotaUnit)).toBe('6.5亿 Tokens/月')
expect(
formatSubscriptionQuota(plans[0].quotaValue, plans[0].quotaUnit),
).toBe("3500万 Tokens/月");
expect(
formatSubscriptionQuota(plans[1].quotaValue, plans[1].quotaUnit),
).toBe("6.5亿 Tokens/月");
expect(summarizeSubscriptionPlans(plans)).toEqual({
planCount: 2,
providerCount: 1,
minMonthlyPrice: 39,
})
})
})
});
});
});
it("prefers the largest daily price swing model as pricing lead", () => {
const models = [
normalizeModel({
id: "deepseek/deepseek-v4-flash",
name: "DeepSeek-V4-Flash",
provider_cn: "DeepSeek",
pricing: { input: 0.3, output: 1.2 },
data_confidence: "official",
}),
normalizeModel({
id: "qwen/qwen-vl-max",
name: "Qwen VL Max",
provider_cn: "阿里云",
pricing: { input: 0.8, output: 2.4 },
data_confidence: "official",
}),
normalizeModel({
id: "glm/glm-5",
name: "GLM-5",
provider_cn: "智谱",
pricing: { input: 0, output: 0 },
is_free: true,
data_confidence: "official",
}),
].filter((model): model is NonNullable<typeof model> => model !== null);
const ranked = [...models].sort(
(a, b) => b.outputPrice - a.outputPrice || b.inputPrice - a.inputPrice,
);
expect(ranked[0].name).toBe("Qwen VL Max");
expect(formatPrice(ranked[0], "output")).toContain("2.4");
});
it("extracts pricing-first report sections from markdown summary", async () => {
const { normalizeLatestReportPayload } = await import("../pages/Dashboard");
const report = normalizeLatestReportPayload({
reportDate: "2026-05-25",
status: "generated",
modelCount: 504,
summaryMD: [
"## 今日结论",
"> 今天最值得关注的是 qwen-vl-max 价格下降 18%,优先复查它是否改变默认选型与预算策略。",
"- 证据: 主来源pricing_history输入价格较昨日下降 18%",
"",
"## 今日行动建议",
"1. **先看 qwen-vl-max** ",
"2. **复查 GLM-5** ",
"",
"## 今日价格新闻",
"### 降价机会",
"#### qwen-vl-max 成本下调 18%",
"- 影响: 视觉模型价格下降已足以影响默认选型。",
"### 平台活动",
"#### DeepSeek-V4-Flash 进入活动窗口",
"- 影响: 平台活动窗口出现后,值得重新评估低成本推理方案。",
"",
"## 场景推荐",
"### 低成本编码",
"- 主推荐: DeepSeek-V4-Flash",
"### 中文通用",
"- 主推荐: GLM-5",
].join("\n"),
markdownUrl: "/report.md",
htmlUrl: "/report.html",
updatedAt: "2026-05-25T10:00:00",
});
expect(report.pricingLead).toContain("qwen-vl-max");
expect(report.pricingLeadNote).toContain("pricing_history");
expect(report.headlines[0].title).toContain("qwen-vl-max");
expect(report.themes[0].title).toBe("降价机会");
expect(report.themes[0].bullets[0]).toContain("qwen-vl-max");
expect(report.themes[1].title).toBe("平台活动");
});

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import * as echarts from 'echarts'
import { useEffect, useRef, useState } from "react";
import * as echarts from "echarts";
import {
formatSubscriptionQuota,
loadFallbackModels,
@@ -10,186 +10,368 @@ import {
summarizeSubscriptionPlans,
type Model,
type SubscriptionPlan,
} from '../lib/models'
} from "../lib/models";
import {
buildApiUnavailableNotice,
buildFallbackNotice,
detectRuntimeEnvironment,
shouldUseLocalFallback,
} from "../lib/runtimeVisibility";
type ReportHeadline = {
label: string;
title: string;
summary: string;
};
type ReportTheme = {
title: string;
bullets: string[];
};
type LatestReport = {
reportDate: string
status: string
modelCount: number
summaryMD: string
markdownUrl: string
htmlUrl: string
updatedAt: string
}
reportDate: string;
status: string;
modelCount: number;
summaryMD: string;
markdownUrl: string;
htmlUrl: string;
updatedAt: string;
pricingLead: string;
pricingLeadNote: string;
headlines: ReportHeadline[];
themes: ReportTheme[];
};
function formatLocalReportDate(date: Date) {
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
function buildFallbackLatestReport(): LatestReport {
const reportDate = formatLocalReportDate(new Date())
const reportDate = formatLocalReportDate(new Date());
return {
reportDate,
status: 'generated',
status: "generated",
modelCount: 0,
summaryMD: '最新日报入口可用,后端元数据暂未返回摘要。',
summaryMD: "最新日报入口可用,后端元数据暂未返回摘要。",
markdownUrl: `/reports/daily/daily_report_${reportDate}.md`,
htmlUrl: `/reports/daily/html/daily_report_${reportDate}.html`,
updatedAt: '',
updatedAt: "",
pricingLead: "当日价格异动摘要暂不可用",
pricingLeadNote: "请直接打开 HTML 日报查看完整价格异动与主题分组。",
headlines: [],
themes: [],
};
}
function extractReportSections(summaryMD: string) {
const normalized = summaryMD.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
const sections = new Map<string, string[]>();
let current = "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("## ")) {
current = trimmed.slice(3).trim();
sections.set(current, []);
continue;
}
if (!current || trimmed === "") {
continue;
}
sections.get(current)?.push(trimmed);
}
return sections;
}
function summarizeLatestReport(report: LatestReport) {
if (report.pricingLead.trim()) {
return report.pricingLead.trim();
}
if (report.summaryMD.trim()) {
return report.summaryMD.trim()
return report.summaryMD.trim();
}
if (report.modelCount > 0) {
return `最新日报已生成,覆盖 ${report.modelCount} 个模型,可直接查看完整 HTML 页面。`
return `最新日报已生成,覆盖 ${report.modelCount} 个模型,可直接查看完整 HTML 页面。`;
}
return '最新日报入口已准备好,可直接打开 HTML 或 Markdown 查看。'
return "最新日报入口已准备好,可直接打开 HTML 或 Markdown 查看。";
}
export function normalizeLatestReportPayload(payload: any): LatestReport {
const summaryMD =
typeof payload?.summaryMD === "string" ? payload.summaryMD : "";
const sections = extractReportSections(summaryMD);
const conclusion = sections.get("今日结论") ?? [];
const changes = sections.get("今日价格新闻") ?? [];
const sceneLines = sections.get("场景推荐") ?? [];
const actionLines = sections.get("今日行动建议") ?? [];
const pricingLead =
conclusion[0]?.replace(/^>\s*/, "") || "当日价格异动摘要暂不可用";
const pricingLeadNote =
changes
.find((line) => line.startsWith("- 证据:"))
?.replace("- 证据:", "")
.trim() ||
conclusion
.find((line) => line.startsWith("- 证据:"))
?.replace("- 证据:", "")
.trim() ||
"请直接打开 HTML 日报查看完整价格异动与主题分组。";
const headlines = actionLines
.filter((line) => /^\d+\./.test(line))
.slice(0, 3)
.map((line) => {
const title = line
.replace(/^\d+\.\s*\*\*/, "")
.replace(/\*\*\s*$/, "")
.trim();
return {
label: "今日动作",
title,
summary: "围绕当天最重要的价格异动与选型影响整理。",
};
});
const sceneThemes: ReportTheme[] = [];
let currentSceneTheme: ReportTheme | null = null;
for (const line of sceneLines) {
if (line.startsWith("### ")) {
currentSceneTheme = { title: line.slice(4).trim(), bullets: [] };
sceneThemes.push(currentSceneTheme);
continue;
}
if (currentSceneTheme && line.startsWith("- ")) {
currentSceneTheme.bullets.push(line.slice(2).trim());
}
}
const pricingThemes: ReportTheme[] = [];
let currentPricingTheme: ReportTheme | null = null;
for (const line of changes) {
if (line.startsWith("### ")) {
currentPricingTheme = { title: line.slice(4).trim(), bullets: [] };
pricingThemes.push(currentPricingTheme);
continue;
}
if (currentPricingTheme && line.startsWith("#### ")) {
currentPricingTheme.bullets.push(line.slice(5).trim());
continue;
}
if (currentPricingTheme && line.startsWith("- 影响: ")) {
currentPricingTheme.bullets.push(line.replace("- 影响: ", ""));
}
}
return {
reportDate: payload?.reportDate,
status: payload?.status || "generated",
modelCount: Number(payload?.modelCount || 0),
summaryMD,
markdownUrl: payload?.markdownUrl,
htmlUrl: payload?.htmlUrl,
updatedAt: payload?.updatedAt || "",
pricingLead,
pricingLeadNote,
headlines,
themes: pricingThemes.length > 0 ? pricingThemes : sceneThemes,
};
}
function reportThemeBadge(themeTitle: string) {
if (themeTitle.includes("降价")) {
return { icon: "↓", label: "Opportunity", tone: "tone-success" };
}
if (themeTitle.includes("涨价")) {
return { icon: "↑", label: "Warning", tone: "tone-caution" };
}
if (themeTitle.includes("活动")) {
return { icon: "✦", label: "Campaign", tone: "tone-promo" };
}
return { icon: "•", label: "Signal", tone: "tone-neutral" };
}
function Dashboard() {
const chartRef = useRef<HTMLDivElement>(null)
const [modelCount, setModelCount] = useState(0)
const [providerCount, setProviderCount] = useState(0)
const [cnyCount, setCnyCount] = useState(0)
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([])
const [planCount, setPlanCount] = useState(0)
const [planMinPrice, setPlanMinPrice] = useState(0)
const [latestReport, setLatestReport] = useState<LatestReport | null>(null)
const [reportFallback, setReportFallback] = useState(false)
const chartRef = useRef<HTMLDivElement>(null);
const [modelCount, setModelCount] = useState(0);
const [providerCount, setProviderCount] = useState(0);
const [cnyCount, setCnyCount] = useState(0);
const [subscriptionPlans, setSubscriptionPlans] = useState<
SubscriptionPlan[]
>([]);
const [planCount, setPlanCount] = useState(0);
const [planMinPrice, setPlanMinPrice] = useState(0);
const [latestReport, setLatestReport] = useState<LatestReport | null>(null);
const [modelsFallback, setModelsFallback] = useState(false);
const [modelsUnavailable, setModelsUnavailable] = useState("");
const [reportFallback, setReportFallback] = useState(false);
const [reportUnavailable, setReportUnavailable] = useState("");
const runtime = detectRuntimeEnvironment();
const modelsFallbackNotice = buildFallbackNotice("models", runtime);
const modelsUnavailableNotice = buildApiUnavailableNotice("models", runtime);
const reportFallbackNotice = buildFallbackNotice("latestReport", runtime);
const reportUnavailableNotice = buildApiUnavailableNotice(
"latestReport",
runtime,
);
useEffect(() => {
let chart: echarts.ECharts | null = null
let disposed = false
let chart: echarts.ECharts | null = null;
let disposed = false;
const renderChart = (models: Model[]) => {
if (!chartRef.current) {
return
return;
}
chart = echarts.init(chartRef.current)
chart = echarts.init(chartRef.current);
const option: echarts.EChartsOption = {
title: { text: '厂商模型分布', left: 'center' },
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
radius: '60%',
data: providerDistribution(models),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
}
chart.setOption(option)
}
title: { text: "厂商模型分布", left: "center" },
tooltip: { trigger: "item" },
series: [
{
type: "pie",
radius: "60%",
data: providerDistribution(models),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
},
],
};
chart.setOption(option);
};
const updateStats = (models: Model[]) => {
const summary = summarizeModels(models)
setModelCount(summary.modelCount)
setProviderCount(summary.providerCount)
setCnyCount(summary.cnyCount)
renderChart(models)
}
const summary = summarizeModels(models);
setModelCount(summary.modelCount);
setProviderCount(summary.providerCount);
setCnyCount(summary.cnyCount);
renderChart(models);
};
const updatePlans = (plans: SubscriptionPlan[]) => {
const summary = summarizeSubscriptionPlans(plans)
setSubscriptionPlans(plans)
setPlanCount(summary.planCount)
setPlanMinPrice(summary.minMonthlyPrice)
}
const summary = summarizeSubscriptionPlans(plans);
setSubscriptionPlans(plans);
setPlanCount(summary.planCount);
setPlanMinPrice(summary.minMonthlyPrice);
};
const loadModels = async () => {
try {
const response = await fetch('/api/v1/models')
const response = await fetch("/api/v1/models");
if (!response.ok) {
throw new Error(`models request failed: ${response.status}`)
throw new Error(`models request failed: ${response.status}`);
}
const payload = await response.json()
const rawModels: any[] = Array.isArray(payload?.data) ? payload.data : []
const payload = await response.json();
const rawModels: any[] = Array.isArray(payload?.data)
? payload.data
: [];
const models = rawModels
.map(normalizeModel)
.filter((model: Model | null): model is Model => model !== null)
.filter((model: Model | null): model is Model => model !== null);
if (!disposed) {
updateStats(models)
updateStats(models);
setModelsFallback(false);
setModelsUnavailable("");
}
} catch {
const fallback = await loadFallbackModels()
if (!disposed) {
updateStats(fallback)
if (shouldUseLocalFallback("models", runtime)) {
const fallback = await loadFallbackModels();
if (!disposed) {
updateStats(fallback);
setModelsFallback(fallback.length > 0);
setModelsUnavailable(
fallback.length === 0 ? modelsUnavailableNotice : "",
);
}
} else if (!disposed) {
updateStats([]);
setModelsFallback(false);
setModelsUnavailable(modelsUnavailableNotice);
}
}
}
};
const loadSubscriptionPlans = async () => {
try {
const response = await fetch('/api/v1/subscription-plans')
const response = await fetch("/api/v1/subscription-plans");
if (!response.ok) {
throw new Error(`subscription plans request failed: ${response.status}`)
throw new Error(
`subscription plans request failed: ${response.status}`,
);
}
const payload = await response.json()
const rawPlans: any[] = Array.isArray(payload?.data) ? payload.data : []
const payload = await response.json();
const rawPlans: any[] = Array.isArray(payload?.data)
? payload.data
: [];
const plans = rawPlans
.map(normalizeSubscriptionPlan)
.filter((plan: SubscriptionPlan | null): plan is SubscriptionPlan => plan !== null)
.filter(
(plan: SubscriptionPlan | null): plan is SubscriptionPlan =>
plan !== null,
);
if (!disposed) {
updatePlans(plans)
updatePlans(plans);
}
} catch {
if (!disposed) {
updatePlans([])
updatePlans([]);
}
}
}
};
const loadLatestReport = async () => {
try {
const response = await fetch('/api/v1/reports/latest')
const response = await fetch("/api/v1/reports/latest");
if (!response.ok) {
throw new Error(`latest report request failed: ${response.status}`)
throw new Error(`latest report request failed: ${response.status}`);
}
const payload = await response.json()
const report = payload?.data
const payload = await response.json();
const report = payload?.data;
if (!report?.reportDate || !report?.htmlUrl || !report?.markdownUrl) {
throw new Error('latest report payload invalid')
throw new Error("latest report payload invalid");
}
if (!disposed) {
setLatestReport({
reportDate: report.reportDate,
status: report.status || 'generated',
modelCount: Number(report.modelCount || 0),
summaryMD: report.summaryMD || '',
markdownUrl: report.markdownUrl,
htmlUrl: report.htmlUrl,
updatedAt: report.updatedAt || '',
})
setReportFallback(false)
setLatestReport(normalizeLatestReportPayload(payload?.data));
setReportFallback(false);
setReportUnavailable("");
}
} catch {
if (!disposed) {
setLatestReport(buildFallbackLatestReport())
setReportFallback(true)
if (shouldUseLocalFallback("latestReport", runtime)) {
if (!disposed) {
setLatestReport(buildFallbackLatestReport());
setReportFallback(true);
setReportUnavailable("");
}
} else if (!disposed) {
setLatestReport(null);
setReportFallback(false);
setReportUnavailable(reportUnavailableNotice);
}
}
}
};
void loadModels()
void loadSubscriptionPlans()
void loadLatestReport()
void loadModels();
void loadSubscriptionPlans();
void loadLatestReport();
return () => {
disposed = true
chart?.dispose()
}
}, [])
disposed = true;
chart?.dispose();
};
}, []);
return (
<div className="dashboard">
@@ -212,56 +394,124 @@ function Dashboard() {
<div className="stat-label"></div>
</div>
</div>
{modelsFallback && (
<div className="runtime-warning" role="alert">
{modelsFallbackNotice}
</div>
)}
{modelsUnavailable && (
<div className="runtime-error" role="alert">
{modelsUnavailable}
</div>
)}
<div className="chart-container">
<div ref={chartRef} style={{ width: '100%', height: '400px' }} />
<div ref={chartRef} style={{ width: "100%", height: "400px" }} />
</div>
<section className="report-section">
<section className="report-section report-priority-section">
<div className="report-header">
<div>
<h3>📰 </h3>
<p>线</p>
<h3>📰 </h3>
<p>
</p>
</div>
{latestReport && (
<span className={`report-status ${latestReport.status === 'generated' ? 'report-status-generated' : 'report-status-other'}`}>
<span
className={`report-status ${latestReport.status === "generated" ? "report-status-generated" : "report-status-other"}`}
>
{latestReport.status}
</span>
)}
</div>
{latestReport ? (
<div className="report-card">
<div className="report-hero">
<div className="report-eyebrow"></div>
<div className="report-summary">{summarizeLatestReport(latestReport)}</div>
<div className="report-card report-priority-card">
<div className="report-hero report-hero-priority">
<div className="report-eyebrow"></div>
<div className="report-summary">
{summarizeLatestReport(latestReport)}
</div>
<div className="report-evidence">
{latestReport.pricingLeadNote}
</div>
</div>
<div className="report-meta">
<span> {latestReport.reportDate}</span>
{latestReport.modelCount > 0 && <span>{latestReport.modelCount} </span>}
{latestReport.updatedAt && <span> {latestReport.updatedAt}</span>}
{latestReport.modelCount > 0 && (
<span>{latestReport.modelCount} </span>
)}
{latestReport.updatedAt && (
<span> {latestReport.updatedAt}</span>
)}
</div>
<div className="report-highlights">
<div className="report-highlight">
<strong></strong>
<span> HTML Markdown </span>
{latestReport.headlines.length > 0 && (
<div className="report-news-grid">
{latestReport.headlines.map((item) => (
<article key={item.title} className="report-news-card">
<div className="report-news-label">{item.label}</div>
<div className="report-news-title">{item.title}</div>
<div className="report-news-summary">{item.summary}</div>
</article>
))}
</div>
<div className="report-highlight">
<strong></strong>
<span></span>
)}
{latestReport.themes.length > 0 && (
<div className="report-theme-grid">
{latestReport.themes.map((theme) => {
const badge = reportThemeBadge(theme.title);
return (
<article
key={theme.title}
className={`report-theme-card ${badge.tone}`}
>
<div className="report-theme-badge">
<span className="report-theme-badge-icon">{badge.icon}</span>
<span className="report-theme-badge-label">{badge.label}</span>
</div>
<div className="report-theme-title">{theme.title}</div>
<ul>
{theme.bullets.slice(0, 3).map((bullet) => (
<li key={bullet}>{bullet}</li>
))}
</ul>
</article>
);
})}
</div>
</div>
)}
<div className="report-actions">
<a className="report-link report-link-primary" href={latestReport.htmlUrl} target="_blank" rel="noreferrer">
<a
className="report-link report-link-primary"
href={latestReport.htmlUrl}
target="_blank"
rel="noreferrer"
>
HTML
</a>
<a className="report-link" href={latestReport.markdownUrl} target="_blank" rel="noreferrer">
<a
className="report-link"
href={latestReport.markdownUrl}
target="_blank"
rel="noreferrer"
>
Markdown
</a>
</div>
{reportFallback && (
<div className="report-note">使退</div>
<div className="report-note runtime-warning-inline">
{reportFallbackNotice}
</div>
)}
{reportUnavailable && (
<div className="report-note runtime-error-inline">
{reportUnavailable}
</div>
)}
</div>
) : (
<div className="subscription-empty"></div>
<div className="data-empty">
API
</div>
)}
</section>
<section className="subscription-section">
@@ -291,18 +541,28 @@ function Dashboard() {
</tr>
</thead>
<tbody>
{subscriptionPlans.map(plan => (
{subscriptionPlans.map((plan) => (
<tr key={plan.planCode}>
<td>
<div className="plan-name">{plan.planName}</div>
<div className="plan-meta">{plan.operatorCN || plan.operator}</div>
<div className="plan-meta">
{plan.operatorCN || plan.operator}
</div>
</td>
<td>¥{plan.listPrice.toFixed(2)}/</td>
<td>{formatSubscriptionQuota(plan.quotaValue, plan.quotaUnit)}</td>
<td>{plan.contextWindow > 0 ? `${Math.round(plan.contextWindow / 1024)}K` : '-'}</td>
<td>
{formatSubscriptionQuota(plan.quotaValue, plan.quotaUnit)}
</td>
<td>
{plan.contextWindow > 0
? `${Math.round(plan.contextWindow / 1024)}K`
: "-"}
</td>
<td>
<div>{plan.modelCount} </div>
{plan.modelPreview && <div className="plan-meta">{plan.modelPreview}</div>}
{plan.modelPreview && (
<div className="plan-meta">{plan.modelPreview}</div>
)}
</td>
</tr>
))}
@@ -311,7 +571,7 @@ function Dashboard() {
)}
</section>
</div>
)
);
}
export default Dashboard
export default Dashboard;

View File

@@ -1,148 +1,298 @@
import { useEffect, useMemo, useState } from 'react'
import { formatPrice, loadFallbackModels, normalizeModel, type Model } from '../lib/models'
import { useEffect, useMemo, useState } from "react";
import {
formatPrice,
loadFallbackModels,
normalizeModel,
type Model,
} from "../lib/models";
import {
buildApiUnavailableNotice,
buildFallbackNotice,
detectRuntimeEnvironment,
shouldUseLocalFallback,
} from "../lib/runtimeVisibility";
type SortField = 'name' | 'inputPrice' | 'outputPrice' | 'contextLength'
type SortOrder = 'asc' | 'desc'
type SortField = "name" | "inputPrice" | "outputPrice" | "contextLength";
const PAGE_SIZE = 5
type SortOrder = "asc" | "desc";
const PAGE_SIZE = 5;
function Explorer() {
const [models, setModels] = useState<Model[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [sortField, setSortField] = useState<SortField>('inputPrice')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [providerFilter, setProviderFilter] = useState<string>('')
const [modalityFilter, setModalityFilter] = useState<string>('')
const [models, setModels] = useState<Model[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [sortField, setSortField] = useState<SortField>("inputPrice");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const [providerFilter, setProviderFilter] = useState<string>("");
const [modalityFilter, setModalityFilter] = useState<string>("");
const [modelsFallback, setModelsFallback] = useState(false);
const [modelsUnavailable, setModelsUnavailable] = useState("");
const runtime = detectRuntimeEnvironment();
const fallbackNotice = buildFallbackNotice("models", runtime);
const unavailableNotice = buildApiUnavailableNotice("models", runtime);
useEffect(() => {
// 从API加载数据
fetch('/api/v1/models')
.then(r => r.json())
.then(data => {
const rawModels: any[] = Array.isArray(data?.data) ? data.data : []
fetch("/api/v1/models")
.then(async (r) => {
if (!r.ok) {
throw new Error(`models request failed: ${r.status}`);
}
return r.json();
})
.then((data) => {
const rawModels: any[] = Array.isArray(data?.data) ? data.data : [];
const normalized = rawModels
.map(normalizeModel)
.filter((model: Model | null): model is Model => model !== null)
setModels(normalized)
setLoading(false)
.filter((model: Model | null): model is Model => model !== null);
setModels(normalized);
setModelsFallback(false);
setModelsUnavailable("");
setLoading(false);
})
.catch(async () => {
// 降级:使用本地静态数据
const fallback = await loadFallbackModels()
setModels(fallback)
setLoading(false)
})
}, [])
if (shouldUseLocalFallback("models", runtime)) {
const fallback = await loadFallbackModels();
setModels(fallback);
setModelsFallback(fallback.length > 0);
setModelsUnavailable(fallback.length === 0 ? unavailableNotice : "");
} else {
setModels([]);
setModelsFallback(false);
setModelsUnavailable(unavailableNotice);
}
setLoading(false);
});
}, []);
// 动态提取厂商列表
const providers = useMemo(() => {
const set = new Set<string>()
models.forEach(m => {
if (m.providerCN && m.providerCN !== 'Unknown') {
set.add(m.providerCN)
const set = new Set<string>();
models.forEach((m) => {
if (m.providerCN && m.providerCN !== "Unknown") {
set.add(m.providerCN);
}
})
return Array.from(set).sort()
}, [models])
});
return Array.from(set).sort();
}, [models]);
// 排序+筛选
const filtered = useMemo(() => {
let result = [...models]
let result = [...models];
if (providerFilter) {
result = result.filter(m => m.providerCN === providerFilter)
result = result.filter((m) => m.providerCN === providerFilter);
}
if (modalityFilter) {
result = result.filter(m => m.modality === modalityFilter)
result = result.filter((m) => m.modality === modalityFilter);
}
result.sort((a, b) => {
const aVal = a[sortField]
const bVal = b[sortField]
if (typeof aVal === 'string') {
return sortOrder === 'asc'
const aVal = a[sortField];
const bVal = b[sortField];
if (typeof aVal === "string") {
return sortOrder === "asc"
? aVal.localeCompare(bVal as string)
: (bVal as string).localeCompare(aVal)
: (bVal as string).localeCompare(aVal);
}
return sortOrder === 'asc'
return sortOrder === "asc"
? (aVal as number) - (bVal as number)
: (bVal as number) - (aVal as number)
})
return result
}, [models, sortField, sortOrder, providerFilter, modalityFilter])
: (bVal as number) - (aVal as number);
});
return result;
}, [models, sortField, sortOrder, providerFilter, modalityFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const pricingFocus = filtered[0] ?? null;
const pricingBoard = filtered.slice(0, 3);
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(o => o === 'asc' ? 'desc' : 'asc')
setSortOrder((o) => (o === "asc" ? "desc" : "asc"));
} else {
setSortField(field)
setSortOrder('asc')
setSortField(field);
setSortOrder("asc");
}
setPage(1)
}
setPage(1);
};
if (loading) return <div className="loading">...</div>
if (loading) return <div className="loading">...</div>;
return (
<div className="explorer">
<h2>🔍 Explorer</h2>
<div className="explorer explorer-editorial">
<div className="explorer-hero">
<div>
<div className="explorer-kicker"></div>
<h2></h2>
<p></p>
</div>
<div className="explorer-hero-meta">
<span> {filtered.length}</span>
<span> {providers.length}</span>
</div>
</div>
<div className="filters">
<select value={providerFilter} onChange={e => { setProviderFilter(e.target.value); setPage(1) }}>
<div className="filters filters-editorial">
<select
value={providerFilter}
onChange={(e) => {
setProviderFilter(e.target.value);
setPage(1);
}}
>
<option value=""></option>
{providers.map(p => <option key={p} value={p}>{p}</option>)}
{providers.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<select value={modalityFilter} onChange={e => { setModalityFilter(e.target.value); setPage(1) }}>
<select
value={modalityFilter}
onChange={(e) => {
setModalityFilter(e.target.value);
setPage(1);
}}
>
<option value=""></option>
<option value="text"></option>
<option value="multimodal"></option>
</select>
<span className="count"> {filtered.length} </span>
</div>
{modelsFallback && (
<div className="runtime-warning" role="alert">
{fallbackNotice}
</div>
)}
{modelsUnavailable && (
<div className="runtime-error" role="alert">
{modelsUnavailable}
</div>
)}
<table className="model-table">
<thead>
<tr>
<th onClick={() => toggleSort('name')}> {sortField === 'name' && (sortOrder === 'asc' ? '▲' : '▼')}</th>
<th></th>
<th></th>
<th onClick={() => toggleSort('inputPrice')}> {sortField === 'inputPrice' && (sortOrder === 'asc' ? '▲' : '▼')}</th>
<th onClick={() => toggleSort('outputPrice')}> {sortField === 'outputPrice' && (sortOrder === 'asc' ? '▲' : '▼')}</th>
<th onClick={() => toggleSort('contextLength')}> {sortField === 'contextLength' && (sortOrder === 'asc' ? '▲' : '▼')}</th>
<th></th>
</tr>
</thead>
<tbody>
{paginated.map(m => (
<tr key={m.id} className={`${m.isFree ? 'free' : ''} ${m.stale ? 'stale' : ''}`.trim()}>
<td>
<div className="model-name">{m.name || m.id}</div>
<div className="model-id">{m.id}</div>
</td>
<td>{m.providerCN || m.provider}</td>
<td>
<span className={`status-badge ${m.stale ? 'status-stale' : 'status-fresh'}`}>
{m.stale ? 'stale' : m.dataConfidence}
</span>
</td>
<td>{formatPrice(m, 'input')}</td>
<td>{formatPrice(m, 'output')}</td>
<td>{(m.contextLength / 1000).toFixed(0)}K</td>
<td>{m.modality}</td>
</tr>
{pricingFocus && (
<section className="pricing-focus-card">
<div className="pricing-focus-header">
<div>
<div className="explorer-kicker"></div>
<h3>{pricingFocus.name || pricingFocus.id}</h3>
<p>
{pricingFocus.providerCN || pricingFocus.provider} ·{" "}
{pricingFocus.modality} ·{" "}
{(pricingFocus.contextLength / 1000).toFixed(0)}K
</p>
</div>
<span
className={`status-badge ${pricingFocus.stale ? "status-stale" : "status-fresh"}`}
>
{pricingFocus.stale ? "stale" : pricingFocus.dataConfidence}
</span>
</div>
<div className="pricing-focus-prices">
<div>
<span></span>
<strong>{formatPrice(pricingFocus, "input")}</strong>
</div>
<div>
<span></span>
<strong>{formatPrice(pricingFocus, "output")}</strong>
</div>
</div>
</section>
)}
{pricingBoard.length > 0 && (
<section className="pricing-board-grid">
{pricingBoard.map((model) => (
<article key={model.id} className="pricing-board-card">
<div className="pricing-board-title">
{model.name || model.id}
</div>
<div className="pricing-board-meta">
{model.providerCN || model.provider} · {model.modality}
</div>
<div className="pricing-board-price">
{formatPrice(model, "input")} / {formatPrice(model, "output")}
</div>
</article>
))}
</tbody>
</table>
</section>
)}
{paginated.length === 0 ? (
<div className="data-empty"></div>
) : (
<table className="model-table model-table-editorial">
<thead>
<tr>
<th onClick={() => toggleSort("name")}>
{sortField === "name" && (sortOrder === "asc" ? "▲" : "▼")}
</th>
<th></th>
<th></th>
<th onClick={() => toggleSort("inputPrice")}>
{" "}
{sortField === "inputPrice" &&
(sortOrder === "asc" ? "▲" : "▼")}
</th>
<th onClick={() => toggleSort("outputPrice")}>
{" "}
{sortField === "outputPrice" &&
(sortOrder === "asc" ? "▲" : "▼")}
</th>
<th onClick={() => toggleSort("contextLength")}>
{" "}
{sortField === "contextLength" &&
(sortOrder === "asc" ? "▲" : "▼")}
</th>
<th></th>
</tr>
</thead>
<tbody>
{paginated.map((m) => (
<tr
key={m.id}
className={`${m.isFree ? "free" : ""} ${m.stale ? "stale" : ""}`.trim()}
>
<td>
<div className="model-name">{m.name || m.id}</div>
<div className="model-id">{m.id}</div>
</td>
<td>{m.providerCN || m.provider}</td>
<td>
<span
className={`status-badge ${m.stale ? "status-stale" : "status-fresh"}`}
>
{m.stale ? "stale" : m.dataConfidence}
</span>
</td>
<td>{formatPrice(m, "input")}</td>
<td>{formatPrice(m, "output")}</td>
<td>{(m.contextLength / 1000).toFixed(0)}K</td>
<td>{m.modality}</td>
</tr>
))}
</tbody>
</table>
)}
<div className="pagination">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)}></button>
<span> {page} / {totalPages} </span>
<button disabled={page === totalPages} onClick={() => setPage(p => p + 1)}></button>
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>
</button>
<span>
{page} / {totalPages}
</span>
<button
disabled={page === totalPages}
onClick={() => setPage((p) => p + 1)}
>
</button>
</div>
</div>
)
);
}
export default Explorer
export default Explorer;