feat(frontend): show subscription plans on dashboard

This commit is contained in:
Your Name
2026-05-13 14:36:28 +08:00
parent ba054f04cf
commit 55e506b2b5
15 changed files with 7241 additions and 124 deletions

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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
View 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
View 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

View 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
View 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
View 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>,
)

View 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
View 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" }]
}

View 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
View 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,
}
})