llm / index.html
nanoppa's picture
Update index.html
20daf5e verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全自动 LLM 情报中心</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body { font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
.spin-slow { animation: spin 2s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
th.sortable { cursor: pointer; user-select: none; transition: background-color 0.2s; }
th.sortable:hover { background-color: #e2e8f0; }
th.active-sort { background-color: #dbeafe; color: #2563eb; }
</style>
</head>
<body class="bg-slate-50 min-h-screen text-slate-800 flex flex-col"> <!-- flex-col 使得 footer 保持在底部 -->
<div id="app" class="container mx-auto px-4 py-8 max-w-7xl flex-grow"> <!-- flex-grow 使得主体内容撑开,让 footer 到底部 -->
<!-- 头部 -->
<div class="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
<div>
<h1 class="text-3xl font-extrabold text-slate-800 bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-purple-600">
<i class="fas fa-sort-amount-down-alt text-blue-600 mr-2"></i>LLM 情报中心
</h1>
<p class="text-slate-500 mt-1 text-sm">支持按上下文、价格排序 | 数据源: OpenRouter API</p>
</div>
<div class="flex gap-3">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input v-model="searchQuery" type="text" placeholder="搜索模型名称/厂商..."
class="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none shadow-sm w-64 transition">
</div>
<button @click="fetchData" :disabled="loading"
class="flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition shadow-md disabled:opacity-70 disabled:cursor-not-allowed">
<i class="fas fa-sync-alt mr-2" :class="{'spin-slow': loading}"></i>
{{ loading ? '正在获取...' : '获取最新数据' }}
</button>
</div>
</div>
<!-- 数据表格 -->
<div class="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs text-slate-500 uppercase bg-slate-100 border-b border-slate-200">
<tr>
<th class="px-6 py-4 font-bold whitespace-nowrap">模型名称 (ID)</th>
<th class="px-6 py-4 font-bold whitespace-nowrap">厂商</th>
<th class="px-6 py-4 font-bold whitespace-nowrap">类型</th>
<th class="px-6 py-4 font-bold whitespace-nowrap">参数量</th>
<th @click="sortBy('context_length')" class="sortable px-6 py-4 font-bold whitespace-nowrap" :class="{'active-sort': currentSort === 'context_length'}">
上下文
<i v-if="currentSort === 'context_length'" class="fas ml-1" :class="currentSortDir === 'asc' ? 'fa-arrow-up' : 'fa-arrow-down'"></i>
<i v-else class="fas fa-sort text-slate-300 ml-1"></i>
</th>
<th @click="sortBy('pricing_prompt')" class="sortable px-6 py-4 font-bold whitespace-nowrap" :class="{'active-sort': currentSort === 'pricing_prompt'}">
输入价格
<i v-if="currentSort === 'pricing_prompt'" class="fas ml-1" :class="currentSortDir === 'asc' ? 'fa-arrow-up' : 'fa-arrow-down'"></i>
<i v-else class="fas fa-sort text-slate-300 ml-1"></i>
</th>
<th @click="sortBy('pricing_completion')" class="sortable px-6 py-4 font-bold whitespace-nowrap" :class="{'active-sort': currentSort === 'pricing_completion'}">
输出价格
<i v-if="currentSort === 'pricing_completion'" class="fas ml-1" :class="currentSortDir === 'asc' ? 'fa-arrow-up' : 'fa-arrow-down'"></i>
<i v-else class="fas fa-sort text-slate-300 ml-1"></i>
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="loading">
<td colspan="7" class="px-6 py-12 text-center text-slate-500">
<i class="fas fa-spinner fa-spin text-3xl mb-3 text-blue-500"></i>
<p>正在获取全球模型数据...</p>
</td>
</tr>
<tr v-else-if="sortedModels.length === 0">
<td colspan="7" class="px-6 py-12 text-center text-slate-500">
<i class="fas fa-inbox text-3xl mb-3 text-gray-300"></i>
<p>暂无数据,请点击右上角按钮获取。</p>
</td>
</tr>
<tr v-for="model in sortedModels" :key="model.id" class="hover:bg-blue-50 transition duration-150">
<td class="px-6 py-4 min-w-[200px]">
<div class="font-bold text-slate-800">{{ model.name }}</div>
<div class="text-xs text-slate-400 font-mono mt-1">{{ model.id }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{{ model.company }}
</td>
<td class="px-6 py-4">
<span :class="getTypeBadgeClass(model.type)" class="px-2 py-1 rounded text-xs font-medium border whitespace-nowrap">
{{ model.type }}
</span>
</td>
<td class="px-6 py-4 font-mono text-slate-600 whitespace-nowrap">
{{ model.parameters }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span class="font-bold text-slate-700 mr-2 w-16 text-right">{{ formatContext(model.context_length) }}</span>
<div class="w-16 bg-gray-200 rounded-full h-1.5 hidden md:block">
<div class="bg-blue-500 h-1.5 rounded-full" :style="{ width: Math.min((model.context_length / 200000) * 100, 100) + '%' }"></div>
</div>
</div>
</td>
<td class="px-6 py-4 font-mono text-xs text-slate-600 whitespace-nowrap">
{{ formatPriceDisplay(model.pricing_prompt) }}
</td>
<td class="px-6 py-4 font-mono text-xs text-slate-600 whitespace-nowrap">
{{ formatPriceDisplay(model.pricing_completion) }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="px-6 py-3 bg-slate-50 border-t border-slate-200 text-xs text-slate-400">
共 {{ sortedModels.length }} 个模型。点击表头可排序。
</div>
</div>
</div>
<!-- 页脚 -->
<footer class="mt-8 py-4 text-center text-slate-500 text-sm">
Powered by 飙猪狂
</footer>
<script>
const { createApp, ref, computed } = Vue;
createApp({
setup() {
const models = ref([]);
const loading = ref(false);
const searchQuery = ref('');
const currentSort = ref('context_length');
const currentSortDir = ref('desc');
const formatContext = (num) => {
if (!num) return '0';
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'k';
return num;
};
const formatPriceDisplay = (val) => {
if (val === 0) return 'Free';
return '$' + val.toFixed(2);
};
const sortBy = (key) => {
if (currentSort.value === key) {
currentSortDir.value = currentSortDir.value === 'asc' ? 'desc' : 'asc';
} else {
currentSort.value = key;
currentSortDir.value = 'desc';
}
};
const processModelData = (apiData) => {
return apiData.map(item => {
const idParts = item.id.split('/');
const companyRaw = idParts[0];
const nameRaw = item.name || idParts[1];
const companyMap = {
'openai': 'OpenAI', 'anthropic': 'Anthropic', 'google': 'Google',
'meta-llama': 'Meta', 'mistralai': 'Mistral AI', 'qwen': 'Alibaba Cloud',
'deepseek': 'DeepSeek', 'microsoft': 'Microsoft'
};
const company = companyMap[companyRaw] || companyRaw.charAt(0).toUpperCase() + companyRaw.slice(1);
const paramMatch = nameRaw.match(/(\d+)[bB]/);
let parameters = paramMatch ? paramMatch[0].toUpperCase() : '-';
if (nameRaw.includes('GPT-4')) parameters = 'Unknown';
let type = '文生文';
const lowerName = nameRaw.toLowerCase();
if (lowerName.includes('vision') || lowerName.includes('vl') || lowerName.includes('4o')) type = '多模态';
else if (lowerName.includes('code')) type = '代码';
const p_prompt = parseFloat(item.pricing?.prompt || 0) * 1000000;
const p_compl = parseFloat(item.pricing?.completion || 0) * 1000000;
return {
id: item.id,
name: item.name,
company: company,
context_length: Number(item.context_length || 0),
pricing_prompt: p_prompt,
pricing_completion: p_compl,
parameters: parameters,
type: type
};
});
};
const fetchData = async () => {
loading.value = true;
models.value = [];
try {
const response = await fetch('https://openrouter.ai/api/v1/models');
const result = await response.json();
if (result && result.data) {
models.value = processModelData(result.data);
}
} catch (error) {
console.error(error);
alert("获取失败,请重试");
} finally {
loading.value = false;
}
};
const sortedModels = computed(() => {
let filtered = models.value;
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(m =>
m.name.toLowerCase().includes(query) ||
m.company.toLowerCase().includes(query)
);
}
return filtered.sort((a, b) => {
let modifier = currentSortDir.value === 'desc' ? -1 : 1;
let valA = a[currentSort.value];
let valB = b[currentSort.value];
if (valA < valB) return -1 * modifier;
if (valA > valB) return 1 * modifier;
return 0;
});
});
const getTypeBadgeClass = (type) => {
if (type.includes('多模态')) return 'bg-purple-100 text-purple-700 border-purple-200';
if (type.includes('代码')) return 'bg-yellow-100 text-yellow-700 border-yellow-200';
return 'bg-blue-50 text-blue-600 border-blue-200';
};
return {
loading,
searchQuery,
fetchData,
sortedModels,
formatContext,
formatPriceDisplay,
getTypeBadgeClass,
sortBy,
currentSort,
currentSortDir
};
}
}).mount('#app');
</script>
</body>
</html>