This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generated_at": "2026-05-08T13:47:39+08:00",
|
||||
"generated_at": "2026-05-09T21:30:54+08:00",
|
||||
"total": 2,
|
||||
"free": 1,
|
||||
"paid": 1,
|
||||
|
||||
@@ -1,248 +1,148 @@
|
||||
// Explorer.tsx - 模型浏览器页面
|
||||
// 组合筛选 + 卡片/表格视图 + 搜索
|
||||
// Phase 1 脚手架:数据来自日报生成命令可重放的 reports/daily JSON
|
||||
import React, { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatPrice, loadFallbackModels, normalizeModel, type Model } from '../lib/models'
|
||||
|
||||
// 筛选栏
|
||||
interface Filters {
|
||||
provider: string;
|
||||
modality: string;
|
||||
maxInputPrice: string;
|
||||
keyword: string;
|
||||
}
|
||||
type SortField = 'name' | 'inputPrice' | 'outputPrice' | 'contextLength'
|
||||
type SortOrder = 'asc' | 'desc'
|
||||
|
||||
// 视图模式
|
||||
type ViewMode = 'card' | 'table';
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
// 模型数据占位(TODO: 接入真实 API)
|
||||
interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextLength: number;
|
||||
inputPrice: number;
|
||||
outputPrice: number;
|
||||
isFree: boolean;
|
||||
capabilities: string[];
|
||||
}
|
||||
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>('')
|
||||
|
||||
// mapAPIResponseToModels — 将 fetch_openrouter.go 输出映射为 Model 结构
|
||||
function mapAPIResponseToModels(raw: any[]): Model[] {
|
||||
return raw.map((m) => ({
|
||||
id: m.id || '',
|
||||
name: m.name || '',
|
||||
provider: (m.id || '').split('/')[0] || '',
|
||||
contextLength: m.context_length || 0,
|
||||
inputPrice: m.pricing?.input ?? 0,
|
||||
outputPrice: m.pricing?.output ?? 0,
|
||||
isFree: (m.pricing?.input ?? 0) === 0 && (m.pricing?.output ?? 0) === 0,
|
||||
capabilities: Array.isArray(m.capabilities) ? m.capabilities : [],
|
||||
}));
|
||||
}
|
||||
useEffect(() => {
|
||||
// 从API加载数据
|
||||
fetch('/api/v1/models')
|
||||
.then(r => 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)
|
||||
})
|
||||
.catch(async () => {
|
||||
// 降级:使用本地静态数据
|
||||
const fallback = await loadFallbackModels()
|
||||
setModels(fallback)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// getMockModels — 优先从 latest_models.json 加载,缺失时 fallback 到 models.json
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const rawData: any = (function() {
|
||||
try {
|
||||
return require('../data/latest_models.json');
|
||||
} catch(e) {
|
||||
return require('../data/models.json');
|
||||
}
|
||||
})();
|
||||
function getMockModels(): Model[] {
|
||||
return mapAPIResponseToModels(rawData.models || []);
|
||||
}
|
||||
|
||||
// filterModels — 四项筛选逻辑:provider/modality/maxInputPrice/keyword(大小写不敏感)
|
||||
function filterModels(models: Model[], filters: Filters): Model[] {
|
||||
return models.filter((m) => {
|
||||
if (filters.provider && m.provider.toLowerCase() !== filters.provider.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
if (filters.modality && !m.capabilities.includes(filters.modality)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxInputPrice && m.inputPrice > parseFloat(filters.maxInputPrice)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.keyword) {
|
||||
const kw = filters.keyword.toLowerCase();
|
||||
if (!m.id.toLowerCase().includes(kw) && !m.name.toLowerCase().includes(kw)) {
|
||||
return false;
|
||||
// 动态提取厂商列表
|
||||
const providers = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
models.forEach(m => {
|
||||
if (m.providerCN && m.providerCN !== 'Unknown') {
|
||||
set.add(m.providerCN)
|
||||
}
|
||||
})
|
||||
return Array.from(set).sort()
|
||||
}, [models])
|
||||
|
||||
// 排序+筛选
|
||||
const filtered = useMemo(() => {
|
||||
let result = [...models]
|
||||
if (providerFilter) {
|
||||
result = result.filter(m => m.providerCN === providerFilter)
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
if (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'
|
||||
? aVal.localeCompare(bVal as string)
|
||||
: (bVal as string).localeCompare(aVal)
|
||||
}
|
||||
return sortOrder === 'asc'
|
||||
? (aVal as number) - (bVal as number)
|
||||
: (bVal as number) - (aVal as number)
|
||||
})
|
||||
return result
|
||||
}, [models, sortField, sortOrder, providerFilter, modalityFilter])
|
||||
|
||||
const ExplorerPage: React.FC = () => {
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
provider: '',
|
||||
modality: '',
|
||||
maxInputPrice: '',
|
||||
keyword: '',
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('card');
|
||||
const filteredResults = filterModels(getMockModels(), filters);
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(o => o === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortOrder('asc')
|
||||
}
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const toggleView = (mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
};
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h4 className="mb-3">模型浏览器</h4>
|
||||
<div className="explorer">
|
||||
<h2>🔍 模型 Explorer</h2>
|
||||
|
||||
{/* 价格趋势占位图 */}
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
<h6 className="card-title">价格趋势(占位)</h6>
|
||||
<div
|
||||
id="price-trend-chart"
|
||||
className="border rounded bg-light d-flex align-items-center justify-content-center text-muted small"
|
||||
style={{ width: '100%', height: 200 }}
|
||||
>
|
||||
图表占位区块,后续接入日报 JSON 和 ECharts
|
||||
</div>
|
||||
</div>
|
||||
<div className="filters">
|
||||
<select value={providerFilter} onChange={e => { setProviderFilter(e.target.value); setPage(1) }}>
|
||||
<option value="">全部厂商</option>
|
||||
{providers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<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>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="row mb-3 g-2">
|
||||
<div className="col-md-2">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.provider}
|
||||
onChange={(e) => handleFilterChange('provider', e.target.value)}
|
||||
>
|
||||
<option value="">全部厂商</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.modality}
|
||||
onChange={(e) => handleFilterChange('modality', e.target.value)}
|
||||
>
|
||||
<option value="">全部模态</option>
|
||||
<option value="text">文字</option>
|
||||
<option value="vision">视觉</option>
|
||||
<option value="code">代码</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder="最大输入价($/MT)"
|
||||
value={filters.maxInputPrice}
|
||||
onChange={(e) => handleFilterChange('maxInputPrice', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="搜索模型名称..."
|
||||
value={filters.keyword}
|
||||
onChange={(e) => handleFilterChange('keyword', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="btn-group w-100" role="group">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-outline-primary ${viewMode === 'card' ? 'active' : ''}`}
|
||||
onClick={() => toggleView('card')}
|
||||
>
|
||||
卡片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-outline-primary ${viewMode === 'table' ? 'active' : ''}`}
|
||||
onClick={() => toggleView('table')}
|
||||
>
|
||||
表格
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<div id="results" className="row">
|
||||
{filteredResults.length === 0 ? (
|
||||
<div className="col-12 text-center text-muted py-5">
|
||||
{/* TODO: 接入 reports/daily JSON 数据 */}
|
||||
暂无数据(接入日报 JSON 后自动展示)
|
||||
</div>
|
||||
) : viewMode === 'card' ? (
|
||||
filteredResults.map((model) => (
|
||||
<div key={model.id} className="col-md-4 mb-3">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h6 className="card-title">{model.id}</h6>
|
||||
<p className="card-text text-muted small">
|
||||
{model.provider} · 上下文 {model.contextLength.toLocaleString()} tokens
|
||||
</p>
|
||||
<p className="card-text small">
|
||||
输入 ${model.inputPrice}/MT · 输出 ${model.outputPrice}/MT
|
||||
</p>
|
||||
{model.isFree && (
|
||||
<span className="badge bg-success">免费</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<table className="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>模型</th>
|
||||
<th>厂商</th>
|
||||
<th>上下文长度</th>
|
||||
<th>输入价格</th>
|
||||
<th>输出价格</th>
|
||||
<th>免费</th>
|
||||
<th>特性</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredResults.map((model) => (
|
||||
<tr key={model.id}>
|
||||
<td>{model.id}</td>
|
||||
<td>{model.provider}</td>
|
||||
<td>{model.contextLength.toLocaleString()}</td>
|
||||
<td>${model.inputPrice}/MT</td>
|
||||
<td>${model.outputPrice}/MT</td>
|
||||
<td>
|
||||
{model.isFree && (
|
||||
<span className="badge bg-success">免费</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{model.capabilities.join(', ')}</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>
|
||||
</div>
|
||||
|
||||
{/* 分页占位 */}
|
||||
<nav>
|
||||
<ul className="pagination justify-content-center">
|
||||
{/* TODO: 接入真实分页 */}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ExplorerPage;
|
||||
export default Explorer
|
||||
|
||||
Reference in New Issue
Block a user