feat(report): improve daily intelligence UX and price tracking
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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("平台活动");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user