mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-07 05:42:53 +08:00
feat: 新增模型列表排序功能 + 优化隧道列表卡片样式
- 模型列表支持 7 种排序方式(默认/名称/延迟/上下文) - 隧道列表改为现代化卡片布局,增强视觉效果 - 添加悬停动画、状态徽章、图标等细节优化
This commit is contained in:
@@ -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>
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user