Improve EvalStone web UI paths, custom suites, and results charts.

Move required path fields up front, persist custom benchmark suites, enlarge readable typography, show full suite benchmark names, add capability-domain trend charts with local Chart.js, and polish ranking name display.
This commit is contained in:
sora 2026-07-29 07:38:05 +00:00
parent 7c449d3126
commit 541b0b477d
8 changed files with 655 additions and 129 deletions

View File

@ -304,6 +304,69 @@ def compare_models(
rows.sort(key=lambda x: x['score'], reverse=True)
ranking.append({'benchmark': b, 'rows': rows})
# Capability-domain aggregation: mean score over selected benches in each category
bench_set = set(bench_list)
active_categories = []
for cat in overview['categories']:
items = [b for b in cat['items'] if b in bench_set]
if items:
active_categories.append({
'id': cat['id'],
'name': cat['name'],
'items': items,
})
category_labels = [c['name'] for c in active_categories]
category_series = []
for folder in selected:
scores = []
details = []
for cat in active_categories:
vals = []
for b in cat['items']:
cell = overview['matrix'].get(folder, {}).get(b)
if cell and cell.get('score') is not None:
vals.append(float(cell['score']))
if vals:
avg = sum(vals) / len(vals)
scores.append(round(avg, 6))
else:
avg = None
scores.append(None)
details.append({
'category': cat['name'],
'score': avg,
'n_benchmarks': len(vals),
'benchmarks': cat['items'],
})
model_meta = next((m for m in overview['models'] if m['folder'] == folder), None)
category_series.append({
'folder': folder,
'label': model_meta['model_name'] if model_meta else folder,
'display': folder,
'scores': scores,
'details': details,
})
category_ranking = []
for i, cat in enumerate(active_categories):
rows = []
for s in category_series:
if s['scores'][i] is not None:
rows.append({
'folder': s['folder'],
'label': s['label'],
'score': s['scores'][i],
'n_benchmarks': s['details'][i]['n_benchmarks'],
})
rows.sort(key=lambda x: x['score'], reverse=True)
category_ranking.append({
'category': cat['name'],
'id': cat['id'],
'items': cat['items'],
'rows': rows,
})
return {
'output_dir': overview['output_dir'],
'benchmarks': bench_list,
@ -311,6 +374,9 @@ def compare_models(
'models': [m for m in overview['models'] if m['folder'] in selected],
'series': series,
'ranking': ranking,
'category_labels': category_labels,
'category_series': category_series,
'category_ranking': category_ranking,
'matrix': {
f: {b: overview['matrix'].get(f, {}).get(b) for b in bench_list}
for f in selected

View File

@ -35,10 +35,45 @@ JOBS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_OUTPUT_DIR = Path(run_module.DEFAULT_OUTPUT_DIR)
CUSTOM_SUITES_PATH = DATA_DIR / 'custom_suites.json'
app = FastAPI(title='EvalStone Launch Panel', version='1.0.0')
def _load_custom_suites() -> Dict[str, List[str]]:
if not CUSTOM_SUITES_PATH.exists():
return {}
try:
data = json.loads(CUSTOM_SUITES_PATH.read_text(encoding='utf-8'))
if not isinstance(data, dict):
return {}
out: Dict[str, List[str]] = {}
for name, items in data.items():
if not isinstance(name, str) or not name.strip():
continue
if not isinstance(items, list):
continue
cleaned = [str(x).strip() for x in items if str(x).strip()]
if cleaned:
out[name.strip()] = cleaned
return out
except Exception:
return {}
def _save_custom_suites(suites: Dict[str, List[str]]) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
CUSTOM_SUITES_PATH.write_text(
json.dumps(suites, ensure_ascii=False, indent=2),
encoding='utf-8',
)
class CustomSuiteRequest(BaseModel):
name: str = Field(..., min_length=1)
datasets: List[str] = Field(..., min_length=1)
# ---------------------------------------------------------------------------
# Job store
# ---------------------------------------------------------------------------
@ -208,6 +243,7 @@ def _meta() -> dict:
}
return {
'suites': suites,
'custom_suites': _load_custom_suites(),
'benchmarks': all_benchmarks,
'categories': categories,
'multi_run': run_module.MULTI_RUN_CONFIG,
@ -530,6 +566,44 @@ async def stream_logs(job_id: str, offset: int = 0):
return StreamingResponse(event_gen(), media_type='text/event-stream')
@app.get('/api/custom-suites')
async def list_custom_suites():
return {'suites': _load_custom_suites()}
@app.post('/api/custom-suites')
async def upsert_custom_suite(req: CustomSuiteRequest):
name = req.name.strip()
if not name:
raise HTTPException(status_code=400, detail='组合名称不能为空')
if name in run_module.SUITES:
raise HTTPException(status_code=400, detail=f'名称与内置 suite 冲突: {name}')
datasets = []
seen = set()
for item in req.datasets:
d = str(item).strip()
if not d or d in seen:
continue
seen.add(d)
datasets.append(d)
if not datasets:
raise HTTPException(status_code=400, detail='请至少选择一个 benchmark')
suites = _load_custom_suites()
suites[name] = datasets
_save_custom_suites(suites)
return {'ok': True, 'name': name, 'datasets': datasets, 'suites': suites}
@app.delete('/api/custom-suites/{name}')
async def delete_custom_suite(name: str):
suites = _load_custom_suites()
if name not in suites:
raise HTTPException(status_code=404, detail='自定义组合不存在')
suites.pop(name, None)
_save_custom_suites(suites)
return {'ok': True, 'suites': suites}
@app.get('/api/results/overview')
async def results_overview(output_dir: Optional[str] = None):
root = Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR

View File

@ -2,7 +2,9 @@
const state = {
meta: null,
selectionMode: 'suite',
suiteKind: 'builtin', // builtin | custom
suite: 'official',
customSuites: {},
activeJobId: null,
pollTimer: null,
logOffset: 0,
@ -48,6 +50,16 @@
$('judge_api_url').value = d.judge_api_url || '';
$('judge_max_tokens').value = d.judge_max_tokens || '';
state.suite = d.suite || 'official';
state.suiteKind = 'builtin';
state.customSuites = meta.custom_suites || {};
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSuites() {
@ -58,18 +70,87 @@
const info = suites[name];
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `suite-card${state.suite === name ? ' active' : ''}`;
btn.innerHTML = `<strong>${name}</strong><small>${info.all.length} benchmarks<br>${info.all.slice(0, 6).join(', ')}${info.all.length > 6 ? '…' : ''}</small>`;
btn.className = `suite-card${state.suiteKind === 'builtin' && state.suite === name ? ' active' : ''}`;
const names = info.all.map(escapeHtml).join(', ');
btn.innerHTML = `
<strong>${escapeHtml(name)}</strong>
<span class="suite-count">${info.all.length} benchmarks</span>
<div class="suite-names">${names}</div>
`;
btn.addEventListener('click', () => {
state.suiteKind = 'builtin';
state.suite = name;
renderSuites();
renderCustomSuites();
});
box.appendChild(btn);
});
}
function renderBenchmarks() {
const box = $('benchmarkList');
function renderCustomSuites() {
const box = $('customSuiteList');
const empty = $('customSuiteEmpty');
box.innerHTML = '';
const names = Object.keys(state.customSuites || {});
empty.classList.toggle('hidden', names.length > 0);
names.forEach((name) => {
const items = state.customSuites[name] || [];
const wrap = document.createElement('div');
wrap.className = `suite-card custom${state.suiteKind === 'custom' && state.suite === name ? ' active' : ''}`;
const main = document.createElement('button');
main.type = 'button';
main.className = 'suite-card-main';
main.innerHTML = `
<strong>${escapeHtml(name)}</strong>
<span class="suite-count">${items.length} benchmarks · 自定义</span>
<div class="suite-names">${items.map(escapeHtml).join(', ')}</div>
`;
main.addEventListener('click', () => {
state.suiteKind = 'custom';
state.suite = name;
renderSuites();
renderCustomSuites();
// sync editor checkboxes
const set = new Set(items);
document.querySelectorAll('#customPickList input').forEach((el) => {
el.checked = set.has(el.value);
});
$('customSuiteName').value = name;
updateCustomPickCount();
});
const del = document.createElement('button');
del.type = 'button';
del.className = 'ghost danger-text suite-del';
del.textContent = '删除';
del.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`删除自定义组合「${name}」?`)) return;
try {
const data = await api(`/api/custom-suites/${encodeURIComponent(name)}`, { method: 'DELETE' });
state.customSuites = data.suites || {};
if (state.suiteKind === 'custom' && state.suite === name) {
state.suiteKind = 'builtin';
state.suite = state.meta.defaults?.suite || 'official';
}
renderSuites();
renderCustomSuites();
setMsg(`已删除组合: ${name}`, 'ok');
} catch (err) {
setMsg(err.message, 'error');
}
});
wrap.appendChild(main);
wrap.appendChild(del);
box.appendChild(wrap);
});
}
function renderBenchmarkPicker(containerId, withCountCb) {
const box = $(containerId);
box.innerHTML = '';
const multi = new Set(Object.keys(state.meta.multi_run || {}));
const categories = state.meta.categories || [
@ -79,13 +160,12 @@
categories.forEach((cat) => {
const section = document.createElement('section');
section.className = `bench-category cat-${cat.id}`;
section.dataset.category = cat.id;
const head = document.createElement('div');
head.className = 'bench-cat-head';
head.innerHTML = `
<div class="bench-cat-title">
<strong>${cat.name}</strong>
<strong>${escapeHtml(cat.name)}</strong>
<span class="muted">${cat.items.length}</span>
</div>
<div class="bench-cat-actions">
@ -95,11 +175,11 @@
`;
head.querySelector('.cat-select').addEventListener('click', () => {
section.querySelectorAll('input[type="checkbox"]').forEach((el) => { el.checked = true; });
updateSelectedCount();
withCountCb();
});
head.querySelector('.cat-clear').addEventListener('click', () => {
section.querySelectorAll('input[type="checkbox"]').forEach((el) => { el.checked = false; });
updateSelectedCount();
withCountCb();
});
section.appendChild(head);
@ -110,20 +190,28 @@
const isMulti = multi.has(name);
label.className = `bench-item${isMulti ? ' multi' : ''}`;
label.title = isMulti ? `multi-run x${state.meta.multi_run[name]}` : cat.name;
label.innerHTML = `<input type="checkbox" value="${name}" /><span>${name}</span>`;
label.innerHTML = `<input type="checkbox" value="${escapeHtml(name)}" /><span>${escapeHtml(name)}</span>`;
if (isMulti) {
const tag = document.createElement('em');
tag.className = 'run-tag';
tag.textContent = `×${state.meta.multi_run[name]}`;
label.appendChild(tag);
}
label.querySelector('input').addEventListener('change', updateSelectedCount);
label.querySelector('input').addEventListener('change', withCountCb);
grid.appendChild(label);
});
section.appendChild(grid);
box.appendChild(section);
});
updateSelectedCount();
withCountCb();
}
function renderBenchmarks() {
renderBenchmarkPicker('benchmarkList', updateSelectedCount);
}
function renderCustomPicker() {
renderBenchmarkPicker('customPickList', updateCustomPickCount);
}
function updateSelectedCount() {
@ -131,6 +219,11 @@
$('selectedCount').textContent = `已选 ${n}`;
}
function updateCustomPickCount() {
const n = [...document.querySelectorAll('#customPickList input:checked')].length;
$('customPickCount').textContent = `已勾选 ${n}`;
}
function setMode(mode) {
state.selectionMode = mode;
document.querySelectorAll('.mode-tabs .tab').forEach((el) => {
@ -147,15 +240,25 @@
? excludeRaw.split(',').map((s) => s.trim()).filter(Boolean)
: [];
let selectionMode = state.selectionMode;
let suite = state.suite;
let finalDatasets = datasets;
if (selectionMode === 'suite' && state.suiteKind === 'custom') {
selectionMode = 'datasets';
finalDatasets = [...(state.customSuites[state.suite] || [])];
suite = state.suite;
}
const payload = {
model: $('model').value.trim(),
api_url: $('api_url').value.trim(),
api_key: $('api_key').value.trim() || 'EMPTY',
thinking: $('thinking').checked,
selection_mode: state.selectionMode,
suite: state.suite,
datasets,
exclude,
selection_mode: selectionMode,
suite,
datasets: finalDatasets,
exclude: selectionMode === 'suite' ? exclude : [],
folder_name: $('folder_name').value.trim() || null,
limit: $('limit').value.trim() || null,
seed: Number($('seed').value || 42),
@ -177,6 +280,19 @@
return payload;
}
async function saveCustomSuite(name, datasets) {
const data = await api('/api/custom-suites', {
method: 'POST',
body: JSON.stringify({ name, datasets }),
});
state.customSuites = data.suites || {};
state.suiteKind = 'custom';
state.suite = name;
renderSuites();
renderCustomSuites();
setMsg(`已永久保存组合: ${name}${datasets.length} 个)`, 'ok');
}
function statusClass(status) {
return status || 'idle';
}
@ -215,10 +331,10 @@
const thinking = job.payload?.thinking ? 'thinking' : 'no-thinking';
btn.innerHTML = `
<div class="row">
<strong>${job.id}</strong>
<span class="badge ${statusClass(job.status)}">${job.status}</span>
<strong>${escapeHtml(job.id)}</strong>
<span class="badge ${statusClass(job.status)}">${escapeHtml(job.status)}</span>
</div>
<small>${model} · ${thinking}</small>
<small>${escapeHtml(model)} · ${thinking}</small>
`;
btn.addEventListener('click', () => followJob(job.id, true));
if (job.id === activeId) btn.style.borderColor = 'rgba(214,162,74,0.75)';
@ -302,7 +418,9 @@
state.meta = await api('/api/meta');
fillDefaults(state.meta);
renderSuites();
renderCustomSuites();
renderBenchmarks();
renderCustomPicker();
setMode('suite');
const jobs = await refreshJobs();
@ -326,12 +444,73 @@
});
$('refreshJobsBtn').addEventListener('click', () => refreshJobs().catch((e) => setMsg(e.message, 'error')));
$('saveCustomSuiteBtn').addEventListener('click', async () => {
const name = $('customSuiteName').value.trim();
const datasets = [...document.querySelectorAll('#customPickList input:checked')].map((el) => el.value);
if (!name) {
setMsg('请填写组合名称', 'error');
return;
}
if (!datasets.length) {
setMsg('请至少勾选一个 benchmark', 'error');
return;
}
try {
await saveCustomSuite(name, datasets);
} catch (err) {
setMsg(err.message, 'error');
}
});
$('loadCheckedToCustomBtn').addEventListener('click', () => {
// no-op helper text: already editing in place; just focus name
$('customSuiteName').focus();
setMsg('请在下方勾选数据集并填写组合名称后保存', 'ok');
});
$('saveFromDatasetsBtn').addEventListener('click', async () => {
const datasets = [...document.querySelectorAll('#benchmarkList input:checked')].map((el) => el.value);
if (!datasets.length) {
setMsg('请先勾选 benchmark', 'error');
return;
}
const name = prompt('输入要永久保存的组合名称:');
if (!name || !name.trim()) return;
try {
// sync into custom picker too
const set = new Set(datasets);
document.querySelectorAll('#customPickList input').forEach((el) => {
el.checked = set.has(el.value);
});
$('customSuiteName').value = name.trim();
updateCustomPickCount();
await saveCustomSuite(name.trim(), datasets);
setMode('suite');
} catch (err) {
setMsg(err.message, 'error');
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
setMsg('');
const requiredPaths = [
['dataset_dir', 'Dataset Dir'],
['output_dir', 'Output Dir'],
['config', 'Config YAML'],
['tokenizer_path', 'Tokenizer Path'],
];
for (const [id, label] of requiredPaths) {
if (!$(id).value.trim()) {
setMsg(`请填写必填路径: ${label}`, 'error');
$(id).focus();
return;
}
}
const payload = collectPayload();
if (payload.selection_mode === 'datasets' && !payload.datasets.length) {
setMsg('请至少选择一个 benchmark', 'error');
setMsg('请至少选择一个 benchmark / 自定义组合', 'error');
return;
}
$('launchBtn').disabled = true;

View File

@ -34,6 +34,28 @@
<main class="layout">
<section class="panel form-panel">
<form id="launchForm">
<div class="block">
<h2>路径配置(必填)</h2>
<div class="grid-2">
<label class="full">
<span>Dataset Dir</span>
<input id="dataset_dir" name="dataset_dir" required />
</label>
<label class="full">
<span>Output Dir</span>
<input id="output_dir" name="output_dir" required />
</label>
<label class="full">
<span>Config YAML</span>
<input id="config" name="config" required />
</label>
<label class="full">
<span>Tokenizer Path</span>
<input id="tokenizer_path" name="tokenizer_path" required />
</label>
</div>
</div>
<div class="block">
<h2>模型接入</h2>
<div class="grid-2">
@ -78,14 +100,36 @@
<div class="block">
<h2>评测范围</h2>
<div class="mode-tabs" role="tablist">
<button type="button" class="tab active" data-mode="suite">按 Suite</button>
<button type="button" class="tab active" data-mode="suite">按 Suite / 自定义组合</button>
<button type="button" class="tab" data-mode="datasets">自选 Benchmark</button>
</div>
<div id="suitePane" class="pane">
<h3 class="subhead">内置 Suite</h3>
<div id="suiteList" class="suite-list"></div>
<h3 class="subhead mt">已保存的自定义组合</h3>
<div id="customSuiteList" class="suite-list"></div>
<p id="customSuiteEmpty" class="muted">暂无自定义组合,可在下方勾选并保存</p>
<div class="custom-editor mt">
<h3 class="subhead">新建 / 更新自定义组合</h3>
<div class="custom-editor-row">
<label>
<span>组合名称</span>
<input id="customSuiteName" placeholder="例如: my_math_code" />
</label>
<div class="custom-editor-actions">
<button type="button" id="saveCustomSuiteBtn" class="primary">永久保存组合</button>
<button type="button" id="loadCheckedToCustomBtn" class="ghost">从下方勾选填入</button>
</div>
</div>
<div id="customPickList" class="benchmark-list compact"></div>
<span id="customPickCount" class="muted">已勾选 0</span>
</div>
<label class="mt">
<span>Exclude逗号分隔可选</span>
<span>Exclude逗号分隔可选,仅对内置 Suite 生效</span>
<input id="exclude" name="exclude" placeholder="例如: tau2_bench,hle" />
</label>
</div>
@ -94,6 +138,7 @@
<div class="bench-actions">
<button type="button" id="selectAllBtn" class="ghost">全选</button>
<button type="button" id="clearAllBtn" class="ghost">清空</button>
<button type="button" id="saveFromDatasetsBtn" class="ghost">保存为自定义组合</button>
<span id="selectedCount" class="muted">已选 0</span>
</div>
<div id="benchmarkList" class="benchmark-list"></div>
@ -101,7 +146,7 @@
</div>
<details class="block advanced">
<summary>高级参数</summary>
<summary>其他参数</summary>
<div class="grid-2 mt">
<label>
<span>Limitnone = 全量)</span>
@ -122,22 +167,6 @@
<option value="false"></option>
</select>
</label>
<label>
<span>Dataset Dir</span>
<input id="dataset_dir" name="dataset_dir" />
</label>
<label>
<span>Output Dir</span>
<input id="output_dir" name="output_dir" />
</label>
<label>
<span>Config YAML</span>
<input id="config" name="config" />
</label>
<label>
<span>Tokenizer Path</span>
<input id="tokenizer_path" name="tokenizer_path" />
</label>
<label>
<span>Judge Model</span>
<input id="judge_model" name="judge_model" />

View File

@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js"></script>
<script src="/static/vendor/chart.umd.min.js"></script>
</head>
<body>
<div class="page">
@ -65,6 +65,26 @@
</section>
<section class="panel charts-panel">
<div class="chart-block">
<div class="side-head">
<h2>能力分类走势</h2>
<span class="muted">横轴 = 能力域(数学/代码/智能体等),纵轴 = 该域平均分</span>
</div>
<div class="chart-wrap">
<canvas id="categoryLineChart"></canvas>
</div>
</div>
<div class="chart-block">
<div class="side-head">
<h2>能力分类柱状对比</h2>
<span class="muted">各模型在同一能力域的平均得分</span>
</div>
<div class="chart-wrap">
<canvas id="categoryBarChart"></canvas>
</div>
</div>
<div class="chart-block">
<div class="side-head">
<h2>得分走势</h2>
@ -117,6 +137,6 @@
</section>
</main>
</div>
<script src="/static/results.js"></script>
<script src="/static/results.js?v=20260729"></script>
</body>
</html>

View File

@ -2,7 +2,7 @@
const state = {
overview: null,
compare: null,
charts: { line: null, bar: null, radar: null },
charts: { line: null, bar: null, radar: null, catLine: null, catBar: null },
};
const $ = (id) => document.getElementById(id);
@ -125,15 +125,66 @@
});
}
function axisOptions(maxRotation = 45) {
return {
x: {
ticks: { color: '#92a197', maxRotation, minRotation: 0, font: { size: 12 } },
grid: { color: 'rgba(51,64,56,0.6)' },
},
y: {
min: 0,
max: 100,
ticks: {
color: '#92a197',
callback: (v) => v + '%',
},
grid: { color: 'rgba(51,64,56,0.6)' },
title: { display: true, text: 'Score (%)', color: '#92a197' },
},
};
}
function markEmpty(el, msg) {
if (!el) return;
const wrap = el.parentElement || el;
wrap.classList.add('empty');
wrap.dataset.empty = msg;
}
function clearEmpty(el) {
if (!el) return;
const wrap = el.parentElement || el;
wrap.classList.remove('empty');
delete wrap.dataset.empty;
}
function makeChart(key, canvas, config) {
if (typeof Chart === 'undefined') {
markEmpty(canvas, 'Chart.js 未加载,请强制刷新页面 (Ctrl+F5)');
return null;
}
if (!canvas) return null;
try {
clearEmpty(canvas);
const chart = new Chart(canvas, config);
state.charts[key] = chart;
return chart;
} catch (err) {
console.error('chart error', key, err);
markEmpty(canvas, `图表渲染失败: ${err.message || err}`);
return null;
}
}
function renderCharts(compare) {
destroyCharts();
const labels = compare.benchmarks;
const labels = compare.benchmarks || [];
const common = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#c9d4cb', boxWidth: 12, font: { family: 'IBM Plex Sans' } },
labels: { color: '#c9d4cb', boxWidth: 12, font: { family: 'IBM Plex Sans', size: 13 } },
},
tooltip: {
callbacks: {
@ -147,60 +198,62 @@
scales: {},
};
state.charts.line = new Chart($('lineChart'), {
type: 'line',
data: { labels, datasets: chartDatasets(compare.series, 'line') },
options: {
...common,
scales: {
x: {
ticks: { color: '#92a197', maxRotation: 45, minRotation: 0 },
grid: { color: 'rgba(51,64,56,0.6)' },
},
y: {
min: 0,
max: 100,
ticks: {
color: '#92a197',
callback: (v) => v + '%',
},
grid: { color: 'rgba(51,64,56,0.6)' },
title: { display: true, text: 'Score (%)', color: '#92a197' },
// Capability-domain charts
const catLabels = compare.category_labels || [];
const catSeries = compare.category_series || [];
const catLineCanvas = $('categoryLineChart');
const catBarCanvas = $('categoryBarChart');
if (!catLabels.length || !catSeries.length) {
markEmpty(catLineCanvas, '当前选择下无可用能力分类数据');
markEmpty(catBarCanvas, '当前选择下无可用能力分类数据');
} else {
makeChart('catLine', catLineCanvas, {
type: 'line',
data: { labels: catLabels, datasets: chartDatasets(catSeries, 'line') },
options: { ...common, scales: axisOptions(0) },
});
makeChart('catBar', catBarCanvas, {
type: 'bar',
data: { labels: catLabels, datasets: chartDatasets(catSeries, 'bar') },
options: {
...common,
scales: {
x: { ...axisOptions(0).x, grid: { display: false } },
y: axisOptions(0).y,
},
},
},
});
});
}
state.charts.bar = new Chart($('barChart'), {
type: 'bar',
data: { labels, datasets: chartDatasets(compare.series, 'bar') },
options: {
...common,
scales: {
x: {
ticks: { color: '#92a197', maxRotation: 45 },
grid: { display: false },
},
y: {
min: 0,
max: 100,
ticks: { color: '#92a197', callback: (v) => v + '%' },
grid: { color: 'rgba(51,64,56,0.6)' },
if (!labels.length) {
markEmpty($('lineChart'), '暂无 benchmark 得分');
markEmpty($('barChart'), '暂无 benchmark 得分');
} else {
makeChart('line', $('lineChart'), {
type: 'line',
data: { labels, datasets: chartDatasets(compare.series, 'line') },
options: { ...common, scales: axisOptions(45) },
});
makeChart('bar', $('barChart'), {
type: 'bar',
data: { labels, datasets: chartDatasets(compare.series, 'bar') },
options: {
...common,
scales: {
x: { ...axisOptions(45).x, grid: { display: false } },
y: axisOptions(45).y,
},
},
},
});
});
}
const radarLabels = labels;
const canRadar = radarLabels.length >= 3 && compare.series.length > 0;
const canRadar = radarLabels.length >= 3 && (compare.series || []).length > 0;
const radarCanvas = $('radarChart');
if (!canRadar) {
radarCanvas.parentElement.classList.add('empty');
radarCanvas.parentElement.dataset.empty = '共同 benchmark 不足 3 个,暂不绘制雷达图';
markEmpty(radarCanvas, '共同 benchmark 不足 3 个,暂不绘制雷达图');
} else {
radarCanvas.parentElement.classList.remove('empty');
delete radarCanvas.parentElement.dataset.empty;
state.charts.radar = new Chart(radarCanvas, {
makeChart('radar', radarCanvas, {
type: 'radar',
data: { labels: radarLabels, datasets: chartDatasets(compare.series, 'radar') },
options: {
@ -216,7 +269,7 @@
ticks: { color: '#92a197', backdropColor: 'transparent', stepSize: 20 },
grid: { color: 'rgba(51,64,56,0.7)' },
angleLines: { color: 'rgba(51,64,56,0.7)' },
pointLabels: { color: '#c9d4cb', font: { size: 11 } },
pointLabels: { color: '#c9d4cb', font: { size: 12 } },
},
},
},

View File

@ -92,24 +92,31 @@ code, pre, .mono { font-family: var(--mono); }
padding: 20px;
}
.block + .block { margin-top: 22px; }
.block + .block { margin-top: 18px; }
.block h2, .side-head h2, .log-head h3, .jobs-head h3 {
margin: 0 0 12px;
font-size: 15px;
margin: 0 0 10px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.04em;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #c7d2c9;
}
.subhead {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: #c7d2c9;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
gap: 5px;
font-size: 14px;
color: var(--muted);
}
label span { letter-spacing: 0.03em; }
label.full { grid-column: 1 / -1; }
label span { letter-spacing: 0.02em; color: #b7c4ba; }
input, select, button, summary {
font: inherit;
@ -119,8 +126,10 @@ input, select {
background: var(--bg0);
color: var(--text);
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
border-radius: 8px;
padding: 7px 10px;
font-size: 15px;
line-height: 1.35;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
@ -170,23 +179,24 @@ input:focus, select:focus {
transform: translateX(18px);
background: var(--accent);
}
.switch-text { color: var(--text); font-size: 14px; }
.switch-text { color: var(--text); font-size: 15px; }
.mode-tabs {
display: inline-flex;
background: var(--bg0);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px;
margin-bottom: 14px;
padding: 3px;
margin-bottom: 12px;
}
.tab {
border: 0;
background: transparent;
color: var(--muted);
padding: 8px 14px;
padding: 7px 12px;
border-radius: 999px;
cursor: pointer;
font-size: 14px;
}
.tab.active {
background: rgba(214, 162, 74, 0.18);
@ -195,40 +205,97 @@ input:focus, select:focus {
.suite-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
grid-template-columns: 1fr;
gap: 8px;
}
.suite-card {
text-align: left;
border: 1px solid var(--line);
background: var(--bg0);
color: var(--text);
border-radius: 12px;
padding: 12px;
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
transition: border-color .15s, transform .15s;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.suite-card:hover { transform: translateY(-1px); }
.suite-card.active {
border-color: rgba(214, 162, 74, 0.75);
background: rgba(214, 162, 74, 0.08);
}
.suite-card strong { display: block; margin-bottom: 4px; }
.suite-card small { color: var(--muted); line-height: 1.4; }
.suite-card.custom {
padding-right: 64px;
}
.suite-card-main {
all: unset;
cursor: pointer;
display: block;
width: 100%;
}
.suite-card strong {
display: block;
margin-bottom: 2px;
font-size: 15px;
}
.suite-count {
display: block;
color: var(--muted);
font-size: 13px;
margin-bottom: 4px;
}
.suite-names {
color: #c9d4cb;
font-size: 13px;
line-height: 1.55;
white-space: normal;
word-break: break-word;
overflow: visible;
text-overflow: unset;
}
.suite-del {
position: absolute;
top: 8px;
right: 8px;
color: #ffb3a8 !important;
}
.danger-text { color: #ffb3a8; }
.custom-editor {
border: 1px dashed var(--line);
border-radius: 10px;
padding: 12px;
background: rgba(12, 16, 14, 0.35);
}
.custom-editor-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: end;
margin-bottom: 10px;
}
.custom-editor-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.benchmark-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
.benchmark-list.compact { max-height: 280px; }
.bench-category {
border: 1px solid var(--line);
border-radius: 12px;
border-radius: 10px;
background: rgba(12, 16, 14, 0.55);
padding: 10px;
padding: 8px;
}
.bench-category.cat-math { border-left: 3px solid #6fbf8a; }
.bench-category.cat-code { border-left: 3px solid #6aa8d6; }
@ -251,32 +318,35 @@ input:focus, select:focus {
gap: 8px;
}
.bench-cat-title strong {
font-size: 13px;
letter-spacing: 0.03em;
font-size: 14px;
letter-spacing: 0.02em;
color: var(--text);
}
.bench-cat-actions { display: flex; gap: 4px; }
.bench-cat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
gap: 6px;
}
.bench-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--bg0);
cursor: pointer;
font-size: 13px;
font-size: 14px;
color: var(--text);
min-height: 0;
}
.bench-item input { width: auto; }
.bench-item.multi { border-color: rgba(111, 191, 138, 0.35); }
.bench-item .run-tag {
margin-left: auto;
font-style: normal;
font-size: 11px;
font-size: 12px;
color: #9ee0b2;
opacity: 0.85;
}
@ -284,7 +354,7 @@ input:focus, select:focus {
.bench-actions {
display: flex; gap: 8px; align-items: center; margin-bottom: 10px;
}
.muted { color: var(--muted); font-size: 12px; }
.muted { color: var(--muted); font-size: 13px; }
.mt { margin-top: 12px; }
.hidden { display: none !important; }
@ -307,12 +377,13 @@ input:focus, select:focus {
margin-top: 22px;
}
button {
border-radius: 10px;
border-radius: 8px;
border: 1px solid var(--line);
padding: 10px 16px;
padding: 8px 14px;
cursor: pointer;
background: var(--bg2);
color: var(--text);
font-size: 14px;
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
button.primary {
@ -328,10 +399,10 @@ button.danger {
}
button.ghost {
background: transparent;
padding: 6px 10px;
font-size: 12px;
padding: 5px 9px;
font-size: 13px;
}
.msg { color: var(--muted); font-size: 13px; }
.msg { color: var(--muted); font-size: 14px; }
.msg.error { color: var(--danger); }
.msg.ok { color: var(--accent-2); }
@ -582,15 +653,29 @@ button.ghost {
}
.rank-row {
display: grid;
grid-template-columns: 28px 1fr auto;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 8px;
font-size: 12px;
padding: 4px 0;
align-items: start;
font-size: 13px;
padding: 6px 0;
color: var(--muted);
}
.rank-idx { color: var(--accent); font-family: var(--mono); }
.rank-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.rank-score { font-family: var(--mono); color: #c9d4cb; }
.rank-name {
color: var(--text);
white-space: normal;
overflow: visible;
text-overflow: unset;
word-break: break-all;
line-height: 1.4;
font-size: 13px;
}
.rank-score {
font-family: var(--mono);
color: #c9d4cb;
white-space: nowrap;
padding-top: 1px;
}
@media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }

20
webui/static/vendor/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long