Files
gao 5cb64f181a feat(api): dashboard 看板 + config 配置页
Dashboard (M3):
  - 路由 /api/dashboard/{daily,history,today,datasets,stats}/
  - daily_summary: 按日期聚合 ok/warning/error/blocked
  - history: 同步历史详情(按 task / 日期过滤)
  - today: 今日 KPI + 任务列表
  - datasets: dataset_registry + 最近 7d run 合并
  - stats: 各表行数 + 日期范围(17 张表)

  HTML SPA: app/api/static/dashboard.html
    - 5 张状态卡片 + 7 天趋势表 + 任务状态表 + 数据规模表 + 历史详情
    - 自动 30s 刷新, 纯 vanilla JS (无框架依赖)

Config (M5):
  - 路由 /api/config/{GET list, GET key, PUT upsert, DELETE}/
  - 凭证类自动脱敏 (MAIRUI_LICENCE / XUEQIU_TOKEN / MAIRUI_RPS_LIMIT)
  - POST /api/config/reload: 把 .env / 环境变量已知项回填到 config 表
  - 分类过滤 + key 搜索 + 凭证提示

  HTML SPA: app/api/static/config.html
    - 列表 + 编辑弹窗 + 删除确认
    - 凭证类编辑时强制重新输入完整值(避免显示脱敏误以为是新值)
    - 一键从环境变量回填

FastAPI 主入口挂载:
  - include_router(dashboard, config)
  - mount('/static', StaticFiles) 提供 HTML
2026-07-07 17:54:05 +08:00

264 lines
11 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>market_sync 参数配置</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; padding: 20px; background: #f6f8fa; color: #24292e; }
h1 { margin: 0 0 8px; font-size: 22px; }
h2 { margin: 24px 0 12px; font-size: 16px; color: #586069; }
.meta { color: #6a737d; font-size: 13px; margin-bottom: 20px; }
.meta a { color: #0969da; text-decoration: none; }
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
select, input, button, textarea {
padding: 6px 10px; border: 1px solid #d0d7de; border-radius: 6px;
background: #fff; font-size: 13px; font-family: inherit;
}
button { cursor: pointer; }
button:hover { background: #f3f4f6; }
button.primary { background: #1f883d; color: #fff; border-color: #1a7f37; }
button.primary:hover { background: #1a7f37; }
button.danger { background: #cf222e; color: #fff; border-color: #cf222e; }
button.danger:hover { background: #b51a24; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d0d7de;
border-radius: 8px; overflow: hidden; font-size: 13px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eaecef; vertical-align: middle; }
th { background: #f6f8fa; font-weight: 600; color: #57606a; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.3px; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f6f8fa; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
background: #eff1f3; padding: 1px 6px; border-radius: 3px; }
.category-pill { display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 11px; background: #ddf4ff; color: #0969da; }
.muted { color: #6a737d; font-size: 12px; }
.alert { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
.alert.info { background: #ddf4ff; color: #0969da; border: 1px solid #b6e3ff; }
.alert.warn { background: #fff8c5; color: #9a6700; border: 1px solid #d4a72c; }
.alert.ok { background: #dafbe1; color: #1a7f37; border: 1px solid #4ac26b; }
input[type=text] { width: 100%; }
td.actions { white-space: nowrap; text-align: right; }
td.actions button { margin-left: 4px; padding: 3px 8px; font-size: 12px; }
.sensitive { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #8250df; }
</style>
</head>
<body>
<h1>⚙️ 参数配置</h1>
<div class="meta">
读写 <code>market_data.config</code> 表。
· <a href="/api/dashboard/">📊 数据看板</a>
· <a href="/docs" target="_blank">FastAPI Docs</a>
</div>
<div id="alert"></div>
<div class="toolbar">
<label>分类:</label>
<select id="catFilter">
<option value="">— 全部 —</option>
</select>
<label>搜索:</label>
<input type="text" id="searchBox" placeholder="按 key 模糊搜索..." style="width: 240px;">
<button onclick="loadConfigs()">刷新</button>
<button onclick="reloadFromEnv()">从环境变量回填</button>
<button class="primary" onclick="showCreate()">+ 新建</button>
</div>
<table id="cfgTable">
<thead><tr>
<th>Key</th><th>Value</th><th>分类</th><th>说明</th>
<th>更新时间</th><th class="actions">操作</th>
</tr></thead>
<tbody><tr><td colspan="6" class="muted">加载中...</td></tr></tbody>
</table>
<!-- 编辑/新建弹窗 -->
<div id="modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.4);
z-index:100; align-items:center; justify-content:center;">
<div style="background:#fff; border-radius:8px; padding:20px; width:520px; max-width:90vw;">
<h3 id="modalTitle">新建配置</h3>
<div style="margin:12px 0;">
<label>Key</label>
<input type="text" id="modalKey" placeholder="例: MAIRUI_LICENCE" style="width:100%;">
</div>
<div style="margin:12px 0;">
<label>Value <span id="sensitiveHint" style="color:#8250df; display:none;">(凭证类,输入完整值;显示时会脱敏)</span></label>
<textarea id="modalValue" rows="3" style="width:100%; font-family:monospace;"></textarea>
</div>
<div style="margin:12px 0;">
<label>分类</label>
<input type="text" id="modalCategory" placeholder="general / credential / scheduler / api / datasource / ..." style="width:100%;">
</div>
<div style="margin:12px 0;">
<label>说明</label>
<input type="text" id="modalDesc" placeholder="可选, 描述这个配置干嘛的" style="width:100%;">
</div>
<div style="text-align:right; margin-top:16px;">
<button onclick="hideModal()">取消</button>
<button class="primary" onclick="saveModal()">保存</button>
</div>
</div>
</div>
<script>
const SENSITIVE = new Set(['MAIRUI_LICENCE', 'XUEQIU_TOKEN', 'MAIRUI_RPS_LIMIT']);
let _allConfigs = [];
let _editingKey = null;
function alertBox(msg, type='info', timeout=4000) {
const el = document.getElementById('alert');
el.innerHTML = `<div class="alert ${type}">${msg}</div>`;
if (timeout > 0) setTimeout(() => el.innerHTML = '', timeout);
}
async function fetchJson(p, opts) {
const r = await fetch(p, opts);
if (!r.ok) {
const t = await r.text();
throw new Error(`HTTP ${r.status}: ${t}`);
}
return r.json();
}
async function loadConfigs() {
try {
const r = await fetchJson('/api/config/');
_allConfigs = r.configs;
// 填充 category 下拉
const cats = await fetchJson('/api/config/categories/list');
const sel = document.getElementById('catFilter');
const cur = sel.value;
sel.innerHTML = '<option value="">— 全部 —</option>' +
cats.categories.map(c => `<option value="${c}">${c}</option>`).join('');
sel.value = cur;
renderTable();
} catch (e) {
alertBox('加载失败: ' + e.message, 'warn');
}
}
function renderTable() {
const cat = document.getElementById('catFilter').value;
const search = document.getElementById('searchBox').value.toLowerCase();
const rows = _allConfigs.filter(r =>
(!cat || r.category === cat) &&
(!search || r.key.toLowerCase().includes(search))
);
const tbody = document.querySelector('#cfgTable tbody');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">没有匹配的配置</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const sensitive = SENSITIVE.has(r.key);
const valDisplay = sensitive
? `<code class="sensitive" title="点击查看完整值 → 编辑时输入新值">${r.value || '(empty)'}</code>`
: `<code>${(r.value || '').slice(0, 80)}${(r.value || '').length > 80 ? '...' : ''}</code>`;
return `<tr>
<td><code>${r.key}</code></td>
<td>${valDisplay}</td>
<td><span class="category-pill">${r.category || '-'}</span></td>
<td class="muted">${r.description || ''}</td>
<td class="muted">${r.updated_at ? new Date(r.updated_at).toLocaleString('zh-CN') : '-'}</td>
<td class="actions">
<button onclick="editConfig('${r.key}')">编辑</button>
<button class="danger" onclick="deleteConfig('${r.key}')">删除</button>
</td>
</tr>`;
}).join('');
}
function showCreate() {
_editingKey = null;
document.getElementById('modalTitle').textContent = '新建配置';
document.getElementById('modalKey').value = '';
document.getElementById('modalKey').disabled = false;
document.getElementById('modalValue').value = '';
document.getElementById('modalCategory').value = 'general';
document.getElementById('modalDesc').value = '';
document.getElementById('sensitiveHint').style.display = 'none';
document.getElementById('modal').style.display = 'flex';
document.getElementById('modalKey').oninput = () => {
const k = document.getElementById('modalKey').value;
document.getElementById('sensitiveHint').style.display =
SENSITIVE.has(k) ? 'inline' : 'none';
};
}
async function editConfig(key) {
try {
const r = await fetchJson(`/api/config/${encodeURIComponent(key)}`);
_editingKey = key;
document.getElementById('modalTitle').textContent = '编辑配置 ' + key;
document.getElementById('modalKey').value = key;
document.getElementById('modalKey').disabled = true;
document.getElementById('modalValue').value = ''; // 编辑凭证类时强制重新输入完整值
document.getElementById('modalValue').placeholder = SENSITIVE.has(key)
? '凭证类 — 输入完整值(显示已脱敏)' : '新值(留空保留原值不可行,PUT 接口会覆盖)';
document.getElementById('modalCategory').value = r.category || 'general';
document.getElementById('modalDesc').value = r.description || '';
document.getElementById('sensitiveHint').style.display =
SENSITIVE.has(key) ? 'inline' : 'none';
document.getElementById('modal').style.display = 'flex';
} catch (e) {
alertBox('加载失败: ' + e.message, 'warn');
}
}
function hideModal() { document.getElementById('modal').style.display = 'none'; }
async function saveModal() {
const key = document.getElementById('modalKey').value.trim();
const value = document.getElementById('modalValue').value;
const category = document.getElementById('modalCategory').value.trim() || 'general';
const description = document.getElementById('modalDesc').value;
if (!key) { alertBox('Key 不能为空', 'warn'); return; }
try {
await fetchJson(`/api/config/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({value, category, description}),
});
hideModal();
alertBox(`✓ 已保存 ${key}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('保存失败: ' + e.message, 'warn');
}
}
async function deleteConfig(key) {
if (!confirm(`确认删除 ${key} ?`)) return;
try {
await fetchJson(`/api/config/${encodeURIComponent(key)}`, {method: 'DELETE'});
alertBox(`✓ 已删除 ${key}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('删除失败: ' + e.message, 'warn');
}
}
async function reloadFromEnv() {
if (!confirm('把 .env / 环境变量里已知配置回填到 config 表(会覆盖)?')) return;
try {
const r = await fetchJson('/api/config/reload', {method: 'POST'});
alertBox(`✓ 已回填 ${r.synced}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('回填失败: ' + e.message, 'warn');
}
}
document.getElementById('catFilter').addEventListener('change', renderTable);
document.getElementById('searchBox').addEventListener('input', renderTable);
loadConfigs();
</script>
</body>
</html>