forked from niuniu/llm-intelligence
feat(frontend): show subscription plans on dashboard
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LLM Intelligence Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5183
frontend/package-lock.json
generated
Normal file
5183
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "llm-intelligence-hub",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^5.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"lighthouse": "^13.3.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.13",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
313
frontend/src/App.css
Normal file
313
frontend/src/App.css
Normal file
@@ -0,0 +1,313 @@
|
||||
/* LLM Intelligence Hub - App Styles */
|
||||
.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.navbar h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.model-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-table th,
|
||||
.model-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.model-table th {
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.model-table tr.free {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.model-table tr.stale {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.model-id {
|
||||
margin-top: 4px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.status-fresh {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-stale {
|
||||
background: #fed7aa;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dashboard h2 {
|
||||
margin: 0 0 16px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f9fafb 100%);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.stat-card.subscription {
|
||||
border-color: #f59e0b;
|
||||
background: linear-gradient(180deg, #fffbeb 0%, #fff7ed 100%);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin-top: 8px;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.subscription-section {
|
||||
padding: 20px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #fffdf5 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.subscription-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.subscription-header h3 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.subscription-header p {
|
||||
margin: 6px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.subscription-summary {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscription-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subscription-table th,
|
||||
.subscription-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.subscription-table th {
|
||||
background: #fffbeb;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.subscription-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.plan-meta {
|
||||
margin-top: 4px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.subscription-empty {
|
||||
padding: 18px;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 10px;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.subscription-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.subscription-summary {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.subscription-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
38
frontend/src/App.tsx
Normal file
38
frontend/src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
import Explorer from './pages/Explorer'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import './App.css'
|
||||
|
||||
type Tab = 'dashboard' | 'explorer'
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('dashboard')
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<nav className="navbar">
|
||||
<h1>🤖 LLM Intelligence Hub</h1>
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={activeTab === 'dashboard' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
>
|
||||
📊 Dashboard
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'explorer' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('explorer')}
|
||||
>
|
||||
🔍 Explorer
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="main">
|
||||
{activeTab === 'dashboard' && <Dashboard />}
|
||||
{activeTab === 'explorer' && <Explorer />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
119
frontend/src/lib/models.test.ts
Normal file
119
frontend/src/lib/models.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatPrice,
|
||||
formatSubscriptionQuota,
|
||||
normalizeModel,
|
||||
normalizeSubscriptionPlan,
|
||||
providerDistribution,
|
||||
summarizeModels,
|
||||
summarizeSubscriptionPlans,
|
||||
} from './models'
|
||||
|
||||
describe('models helpers', () => {
|
||||
it('normalizes fallback pricing and stale flags', () => {
|
||||
const model = normalizeModel({
|
||||
id: 'anthropic/claude-sonnet-4.6',
|
||||
provider_cn: 'Anthropic',
|
||||
context_length: 200000,
|
||||
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)
|
||||
})
|
||||
|
||||
it('marks free models and pricing unavailable correctly', () => {
|
||||
const freeModel = normalizeModel({
|
||||
id: 'qwen/qwen3-coder:free',
|
||||
})
|
||||
const paidModel = normalizeModel({
|
||||
id: 'openai/gpt-4.1',
|
||||
pricing: {},
|
||||
})
|
||||
|
||||
expect(formatPrice(freeModel!, 'input')).toContain('免费')
|
||||
expect(formatPrice(paidModel!, 'input')).toBe('pricing unavailable')
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
expect(summarizeModels(models)).toEqual({
|
||||
modelCount: 3,
|
||||
providerCount: 2,
|
||||
cnyCount: 2,
|
||||
})
|
||||
expect(providerDistribution(models)).toEqual([
|
||||
{ name: 'DeepSeek', value: 2 },
|
||||
{ name: 'Anthropic', value: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
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',
|
||||
listPrice: 39,
|
||||
priceUnit: 'CNY/month',
|
||||
quotaValue: 35000000,
|
||||
quotaUnit: 'tokens/month',
|
||||
contextWindow: 0,
|
||||
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')
|
||||
})
|
||||
|
||||
it('formats subscription quotas and summarizes plan stats', () => {
|
||||
const plans = [
|
||||
normalizeSubscriptionPlan({
|
||||
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'],
|
||||
}),
|
||||
normalizeSubscriptionPlan({
|
||||
planCode: 'hy-token-plan-max',
|
||||
planName: 'Hy Token Plan Max',
|
||||
providerCN: '腾讯',
|
||||
listPrice: 468,
|
||||
quotaValue: 650000000,
|
||||
quotaUnit: 'tokens/month',
|
||||
contextWindow: 262144,
|
||||
modelScope: ['hy3-preview'],
|
||||
}),
|
||||
].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(summarizeSubscriptionPlans(plans)).toEqual({
|
||||
planCount: 2,
|
||||
providerCount: 1,
|
||||
minMonthlyPrice: 39,
|
||||
})
|
||||
})
|
||||
})
|
||||
256
frontend/src/lib/models.ts
Normal file
256
frontend/src/lib/models.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
export interface Model {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
providerCN: string
|
||||
modality: string
|
||||
contextLength: number
|
||||
inputPrice: number
|
||||
outputPrice: number
|
||||
currency: string
|
||||
isFree: boolean
|
||||
stale: boolean
|
||||
pricingAvailable: boolean
|
||||
dataConfidence: string
|
||||
}
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
planFamily: string
|
||||
planCode: string
|
||||
planName: string
|
||||
tier: string
|
||||
provider: string
|
||||
providerCN: string
|
||||
operator: string
|
||||
operatorCN: string
|
||||
currency: string
|
||||
listPrice: number
|
||||
priceUnit: string
|
||||
quotaValue: number
|
||||
quotaUnit: string
|
||||
contextWindow: number
|
||||
modelScope: string[]
|
||||
modelCount: number
|
||||
modelPreview: string
|
||||
sourceUrl: string
|
||||
publishedAt: string
|
||||
effectiveDate: string
|
||||
}
|
||||
|
||||
export function deriveProviderLabel(id: string, fallback?: string) {
|
||||
if (fallback) {
|
||||
return fallback
|
||||
}
|
||||
const provider = id.split('/')[0] || 'unknown'
|
||||
return provider.replace(/[-_]/g, ' ')
|
||||
}
|
||||
|
||||
export function deriveModelName(id: string, fallback?: string) {
|
||||
if (fallback) {
|
||||
return fallback
|
||||
}
|
||||
const raw = id.split('/')[1] || id
|
||||
return raw.replace(/:free$/, '')
|
||||
}
|
||||
|
||||
export function deriveModality(raw: any) {
|
||||
if (typeof raw.modality === 'string' && raw.modality) {
|
||||
return raw.modality
|
||||
}
|
||||
|
||||
const capabilities = Array.isArray(raw.capabilities) ? raw.capabilities : []
|
||||
if (capabilities.includes('vision')) {
|
||||
return 'multimodal'
|
||||
}
|
||||
if (capabilities.includes('code')) {
|
||||
return 'code'
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function normalizePrice(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeModel(raw: any): Model | null {
|
||||
const id = typeof raw?.id === 'string' ? raw.id : ''
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inputPrice = normalizePrice(raw.inputPrice ?? raw.input_price ?? raw.pricing?.input)
|
||||
const outputPrice = normalizePrice(raw.outputPrice ?? raw.output_price ?? raw.pricing?.output)
|
||||
const isFree = Boolean(raw.isFree ?? raw.is_free ?? id.endsWith(':free') ?? false)
|
||||
const pricingAvailable = isFree || (inputPrice !== null && outputPrice !== null)
|
||||
const dataConfidence = typeof raw.dataConfidence === 'string'
|
||||
? raw.dataConfidence
|
||||
: (typeof raw.data_confidence === 'string' ? raw.data_confidence : 'official')
|
||||
|
||||
return {
|
||||
id,
|
||||
name: deriveModelName(id, raw.name),
|
||||
provider: deriveProviderLabel(id, raw.provider),
|
||||
providerCN: deriveProviderLabel(id, raw.providerCN ?? raw.provider_cn ?? raw.provider),
|
||||
modality: deriveModality(raw),
|
||||
contextLength: Number(raw.contextLength ?? raw.context_length ?? 0),
|
||||
inputPrice: inputPrice ?? 0,
|
||||
outputPrice: outputPrice ?? 0,
|
||||
currency: raw.currency ?? raw.pricing?.currency ?? 'USD',
|
||||
isFree,
|
||||
stale: Boolean(raw.stale ?? dataConfidence === 'stale'),
|
||||
pricingAvailable,
|
||||
dataConfidence,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFallbackModels() {
|
||||
const sources = [
|
||||
() => import('../data/latest_models.json'),
|
||||
() => import('../data/models.json'),
|
||||
]
|
||||
|
||||
for (const load of sources) {
|
||||
try {
|
||||
const module = await load()
|
||||
const raw = module.default as any
|
||||
const arr: any[] = Array.isArray(raw) ? raw : (raw.models || [])
|
||||
const normalized = arr
|
||||
.map(normalizeModel)
|
||||
.filter((model: Model | null): model is Model => model !== null)
|
||||
if (normalized.length > 0) {
|
||||
return normalized
|
||||
}
|
||||
} catch {
|
||||
// 继续尝试下一个回退源
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function formatPrice(model: Model, kind: 'input' | 'output') {
|
||||
if (model.isFree) {
|
||||
return '🆓 免费'
|
||||
}
|
||||
if (!model.pricingAvailable) {
|
||||
return 'pricing unavailable'
|
||||
}
|
||||
|
||||
const value = kind === 'input' ? model.inputPrice : model.outputPrice
|
||||
return `${value} ${model.currency}/M`
|
||||
}
|
||||
|
||||
export function summarizeModels(models: Model[]) {
|
||||
const providerSet = new Set<string>()
|
||||
let cnyCount = 0
|
||||
|
||||
for (const model of models) {
|
||||
if (model.providerCN) {
|
||||
providerSet.add(model.providerCN)
|
||||
}
|
||||
if (model.currency === 'CNY') {
|
||||
cnyCount++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modelCount: models.length,
|
||||
providerCount: providerSet.size,
|
||||
cnyCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSubscriptionPlan(raw: any): SubscriptionPlan | null {
|
||||
const planCode = typeof raw?.planCode === 'string' ? raw.planCode : ''
|
||||
if (!planCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modelScope = Array.isArray(raw.modelScope)
|
||||
? raw.modelScope.filter((value: unknown): value is string => typeof value === 'string' && value.length > 0)
|
||||
: []
|
||||
|
||||
return {
|
||||
planFamily: typeof raw.planFamily === 'string' ? raw.planFamily : 'token_plan',
|
||||
planCode,
|
||||
planName: typeof raw.planName === 'string' ? raw.planName : planCode,
|
||||
tier: typeof raw.tier === 'string' ? raw.tier : '',
|
||||
provider: typeof raw.provider === 'string' ? raw.provider : 'unknown',
|
||||
providerCN: typeof raw.providerCN === 'string' ? raw.providerCN : deriveProviderLabel(planCode, raw.providerCN ?? raw.provider),
|
||||
operator: typeof raw.operator === 'string' ? raw.operator : 'unknown',
|
||||
operatorCN: typeof raw.operatorCN === 'string' ? raw.operatorCN : deriveProviderLabel(planCode, raw.operatorCN ?? raw.operator),
|
||||
currency: typeof raw.currency === 'string' ? raw.currency : 'CNY',
|
||||
listPrice: normalizePrice(raw.listPrice) ?? 0,
|
||||
priceUnit: typeof raw.priceUnit === 'string' ? raw.priceUnit : 'CNY/month',
|
||||
quotaValue: typeof raw.quotaValue === 'number' ? raw.quotaValue : Number(raw.quotaValue ?? 0),
|
||||
quotaUnit: typeof raw.quotaUnit === 'string' ? raw.quotaUnit : '',
|
||||
contextWindow: Number(raw.contextWindow ?? 0),
|
||||
modelScope,
|
||||
modelCount: typeof raw.modelCount === 'number' ? raw.modelCount : modelScope.length,
|
||||
modelPreview: typeof raw.modelPreview === 'string'
|
||||
? raw.modelPreview
|
||||
: modelScope.slice(0, 3).join(', '),
|
||||
sourceUrl: typeof raw.sourceUrl === 'string' ? raw.sourceUrl : '',
|
||||
publishedAt: typeof raw.publishedAt === 'string' ? raw.publishedAt : '',
|
||||
effectiveDate: typeof raw.effectiveDate === 'string' ? raw.effectiveDate : '',
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSubscriptionQuota(value: number, unit: string) {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
if (unit === 'tokens/month') {
|
||||
if (value >= 100000000 && value % 100000000 === 0) {
|
||||
return `${value / 100000000}亿 Tokens/月`
|
||||
}
|
||||
if (value >= 100000000) {
|
||||
return `${(value / 100000000).toFixed(1)}亿 Tokens/月`
|
||||
}
|
||||
if (value >= 10000 && value % 10000 === 0) {
|
||||
return `${value / 10000}万 Tokens/月`
|
||||
}
|
||||
}
|
||||
return `${value} ${unit}`
|
||||
}
|
||||
|
||||
export function summarizeSubscriptionPlans(plans: SubscriptionPlan[]) {
|
||||
const providerSet = new Set<string>()
|
||||
let minMonthlyPrice = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const plan of plans) {
|
||||
if (plan.providerCN) {
|
||||
providerSet.add(plan.providerCN)
|
||||
}
|
||||
if (plan.listPrice > 0 && plan.listPrice < minMonthlyPrice) {
|
||||
minMonthlyPrice = plan.listPrice
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
planCount: plans.length,
|
||||
providerCount: providerSet.size,
|
||||
minMonthlyPrice: Number.isFinite(minMonthlyPrice) ? minMonthlyPrice : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function providerDistribution(models: Model[]) {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
for (const model of models) {
|
||||
const key = model.providerCN || model.provider || 'Unknown'
|
||||
counts.set(key, (counts.get(key) || 0) + 1)
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 8)
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
194
frontend/src/pages/Dashboard.tsx
Normal file
194
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import {
|
||||
formatSubscriptionQuota,
|
||||
loadFallbackModels,
|
||||
normalizeModel,
|
||||
normalizeSubscriptionPlan,
|
||||
providerDistribution,
|
||||
summarizeModels,
|
||||
summarizeSubscriptionPlans,
|
||||
type Model,
|
||||
type SubscriptionPlan,
|
||||
} from '../lib/models'
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
let chart: echarts.ECharts | null = null
|
||||
let disposed = false
|
||||
|
||||
const renderChart = (models: Model[]) => {
|
||||
if (!chartRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const updateStats = (models: Model[]) => {
|
||||
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 loadModels = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/models')
|
||||
if (!response.ok) {
|
||||
throw new Error(`models request failed: ${response.status}`)
|
||||
}
|
||||
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)
|
||||
if (!disposed) {
|
||||
updateStats(models)
|
||||
}
|
||||
} catch {
|
||||
const fallback = await loadFallbackModels()
|
||||
if (!disposed) {
|
||||
updateStats(fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadSubscriptionPlans = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/subscription-plans')
|
||||
if (!response.ok) {
|
||||
throw new Error(`subscription plans request failed: ${response.status}`)
|
||||
}
|
||||
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)
|
||||
if (!disposed) {
|
||||
updatePlans(plans)
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
updatePlans([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadModels()
|
||||
void loadSubscriptionPlans()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
chart?.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<h2>📊 数据概览</h2>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{modelCount}</div>
|
||||
<div className="stat-label">模型总数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{providerCount}</div>
|
||||
<div className="stat-label">国内厂商</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{cnyCount}</div>
|
||||
<div className="stat-label">CNY定价</div>
|
||||
</div>
|
||||
<div className="stat-card subscription">
|
||||
<div className="stat-value">{planCount}</div>
|
||||
<div className="stat-label">腾讯云套餐</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<div ref={chartRef} style={{ width: '100%', height: '400px' }} />
|
||||
</div>
|
||||
<section className="subscription-section">
|
||||
<div className="subscription-header">
|
||||
<div>
|
||||
<h3>💳 腾讯云套餐订阅价</h3>
|
||||
<p>订阅套餐和按模型价格分开展示,不混入模型排行。</p>
|
||||
</div>
|
||||
{planCount > 0 && (
|
||||
<div className="subscription-summary">
|
||||
<span>{planCount} 个套餐</span>
|
||||
<span>最低 ¥{planMinPrice.toFixed(0)}/月</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscriptionPlans.length === 0 ? (
|
||||
<div className="subscription-empty">当前暂无套餐数据。</div>
|
||||
) : (
|
||||
<table className="subscription-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>套餐</th>
|
||||
<th>月费</th>
|
||||
<th>月额度</th>
|
||||
<th>上下文</th>
|
||||
<th>覆盖模型</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscriptionPlans.map(plan => (
|
||||
<tr key={plan.planCode}>
|
||||
<td>
|
||||
<div className="plan-name">{plan.planName}</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>
|
||||
<div>{plan.modelCount} 个模型</div>
|
||||
{plan.modelPreview && <div className="plan-meta">{plan.modelPreview}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user