feat: 新增模型列表排序功能 + 优化隧道列表卡片样式

- 模型列表支持 7 种排序方式(默认/名称/延迟/上下文)
- 隧道列表改为现代化卡片布局,增强视觉效果
- 添加悬停动画、状态徽章、图标等细节优化
This commit is contained in:
晴天
2026-02-28 15:00:54 +08:00
parent 1d64fdcce7
commit 6946ffda17
3 changed files with 209 additions and 17 deletions

View File

@@ -102,20 +102,38 @@ function renderCftunnel(el, s) {
}
function renderRoutes(routes) {
if (!routes.length) return '<div style="color:var(--text-tertiary)">暂无路由</div>'
if (!routes.length) return '<div style="color:var(--text-tertiary);padding:var(--space-md) 0">暂无路由</div>'
return `
<table class="data-table" style="margin-bottom:0">
<thead><tr><th>名称</th><th>域名</th><th>本地服务</th></tr></thead>
<tbody>
${routes.map(r => `
<tr>
<td>${r.name}</td>
<td><a href="https://${r.domain}" target="_blank" rel="noopener">${r.domain}</a></td>
<td><code>${r.service}</code></td>
</tr>
`).join('')}
</tbody>
</table>
<div class="tunnel-routes">
${routes.map(r => `
<div class="tunnel-route-card">
<div class="tunnel-route-header">
<span class="tunnel-route-name">${escapeHtml(r.name)}</span>
<span class="tunnel-route-badge">
<span class="status-dot running" style="width:6px;height:6px"></span>
活跃
</span>
</div>
<div class="tunnel-route-domain">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
<a href="https://${escapeHtml(r.domain)}" target="_blank" rel="noopener">${escapeHtml(r.domain)}</a>
</div>
<div class="tunnel-route-service">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-tertiary)">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<span>本地服务:</span>
<code>${escapeHtml(r.service)}</code>
</div>
</div>
`).join('')}
</div>
`
}

View File

@@ -135,6 +135,58 @@ function renderDefaultBar(page, state) {
`
}
// 排序模型列表
function sortModels(models, sortBy) {
if (!sortBy || sortBy === 'default') return models
const sorted = [...models]
switch (sortBy) {
case 'name-asc':
sorted.sort((a, b) => {
const nameA = (a.name || a.id || '').toLowerCase()
const nameB = (b.name || b.id || '').toLowerCase()
return nameA.localeCompare(nameB)
})
break
case 'name-desc':
sorted.sort((a, b) => {
const nameA = (a.name || a.id || '').toLowerCase()
const nameB = (b.name || b.id || '').toLowerCase()
return nameB.localeCompare(nameA)
})
break
case 'latency-asc':
sorted.sort((a, b) => {
const latA = a.latency ?? Infinity
const latB = b.latency ?? Infinity
return latA - latB
})
break
case 'latency-desc':
sorted.sort((a, b) => {
const latA = a.latency ?? -1
const latB = b.latency ?? -1
return latB - latA
})
break
case 'context-asc':
sorted.sort((a, b) => {
const ctxA = a.contextWindow ?? 0
const ctxB = b.contextWindow ?? 0
return ctxA - ctxB
})
break
case 'context-desc':
sorted.sort((a, b) => {
const ctxA = a.contextWindow ?? 0
const ctxB = b.contextWindow ?? 0
return ctxB - ctxA
})
break
}
return sorted
}
// 渲染服务商列表(渲染完后直接绑定事件)
function renderProviders(page, state) {
const listEl = page.querySelector('#providers-list')
@@ -142,6 +194,7 @@ function renderProviders(page, state) {
const keys = Object.keys(providers)
const primary = getCurrentPrimary(state.config)
const search = state.search || ''
const sortBy = state.sortBy || 'default'
if (!keys.length) {
listEl.innerHTML = `
@@ -161,7 +214,8 @@ function renderProviders(page, state) {
return id.includes(search) || name.includes(search)
})
: models
const hiddenCount = models.length - filtered.length
const sorted = sortModels(filtered, sortBy)
const hiddenCount = models.length - sorted.length
return `
<div class="config-section" data-provider="${key}">
<div class="config-section-title" style="display:flex;justify-content:space-between;align-items:center">
@@ -174,13 +228,25 @@ function renderProviders(page, state) {
</div>
</div>
${models.length >= 2 ? `
<div style="display:flex;gap:6px;margin-bottom:var(--space-sm)">
<div style="display:flex;gap:6px;margin-bottom:var(--space-sm);align-items:center">
<button class="btn btn-sm btn-secondary" data-action="batch-test">批量测试</button>
<button class="btn btn-sm btn-secondary" data-action="select-all">全选</button>
<button class="btn btn-sm btn-danger" data-action="batch-delete">批量删除</button>
<div style="margin-left:auto;display:flex;gap:6px;align-items:center">
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">排序:</span>
<select class="form-input" data-action="sort-models" style="padding:4px 8px;font-size:var(--font-size-xs);width:auto">
<option value="default" ${sortBy === 'default' ? 'selected' : ''}>默认顺序</option>
<option value="name-asc" ${sortBy === 'name-asc' ? 'selected' : ''}>名称 A-Z</option>
<option value="name-desc" ${sortBy === 'name-desc' ? 'selected' : ''}>名称 Z-A</option>
<option value="latency-asc" ${sortBy === 'latency-asc' ? 'selected' : ''}>延迟 低→高</option>
<option value="latency-desc" ${sortBy === 'latency-desc' ? 'selected' : ''}>延迟 高→低</option>
<option value="context-asc" ${sortBy === 'context-asc' ? 'selected' : ''}>上下文 小→大</option>
<option value="context-desc" ${sortBy === 'context-desc' ? 'selected' : ''}>上下文 大→小</option>
</select>
</div>
</div>` : ''}
<div class="provider-models">
${renderModelCards(key, filtered, primary, search)}
${renderModelCards(key, sorted, primary, search)}
${hiddenCount > 0 ? `<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);padding:4px 0">已隐藏 ${hiddenCount} 个不匹配的模型</div>` : ''}
</div>
</div>
@@ -305,7 +371,16 @@ function updateUndoBtn(page, state) {
// 渲染完成后,直接给每个 [data-action] 按钮绑定 onclick
function bindProviderButtons(listEl, page, state) {
listEl.querySelectorAll('[data-action]').forEach(btn => {
// 绑定排序下拉框
listEl.querySelectorAll('select[data-action="sort-models"]').forEach(select => {
select.onchange = (e) => {
state.sortBy = e.target.value
renderProviders(page, state)
}
})
// 绑定按钮
listEl.querySelectorAll('button[data-action], input[data-action]').forEach(btn => {
const action = btn.dataset.action
const section = btn.closest('[data-provider]')
if (!section) return

View File

@@ -215,3 +215,102 @@
white-space: pre-wrap;
word-break: break-all;
}
/* 隧道路由卡片 */
.tunnel-routes {
display: grid;
gap: var(--space-md);
margin-top: var(--space-md);
}
.tunnel-route-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-lg);
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.tunnel-route-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
opacity: 0;
transition: opacity var(--transition-fast);
}
.tunnel-route-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-focus);
}
.tunnel-route-card:hover::before {
opacity: 1;
}
.tunnel-route-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.tunnel-route-name {
font-weight: 600;
font-size: var(--font-size-md);
color: var(--text-primary);
}
.tunnel-route-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
background: var(--success-muted);
color: var(--success);
}
.tunnel-route-domain {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.tunnel-route-domain a {
color: var(--accent);
text-decoration: none;
font-size: var(--font-size-sm);
transition: color var(--transition-fast);
}
.tunnel-route-domain a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.tunnel-route-service {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.tunnel-route-service code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}