Compare commits

..
2 Commits
Author SHA1 Message Date
hz4th_coder b6b9e9148c UI 优化: Markdown 切换开关移入标题行 + 分页控件顶部复制/每页条数可调
- 任务详情弹窗: 📝 Markdown/原文 切换开关移到「任务指令」「产出物」标题右侧
  (标题左、开关右, flex 布局, 不再挤占文本区域宽度)
- 运行日志/告警中心: 分页控件顶部底部各一份; 新增每页条数选择(10/20/50/100),
  改条数自动回到第1页, 顶部/底部跳页双向同步
2026-08-16 19:51:32 +08:00
hz4th_coder affa335e8a V3.3 功能: 日志/告警分页筛选 + 项目状态筛选/删除/改状态 + 任务详情 Markdown
- 运行日志: 分页(快速跳页/上一页/下一页) + 级别筛选(全部/信息/成功/警告/错误) + 关键词搜索
- 告警中心: 分页 + 级别/类型/已读未读筛选
- 项目列表: 状态筛选(全部/规划中/进行中/待验收/已完成/已归档, 带计数, 默认全部) + 卡片内状态下拉直接改状态 + 删除按钮(带确认, 权限管理级)
- 任务详情弹窗: 任务指令/产出物按 Markdown 渲染(内置轻量渲染器, 无外部依赖, 支持标题/加粗/斜体/代码块/列表/引用/表格/链接), 可一键切换原文视图, 默认 Markdown
- 后端: /api/logs /api/alerts 支持 page/page_size/level/type/read/q 参数并返回 total; /api/projects 支持 status 筛选
2026-08-16 19:41:04 +08:00
3 changed files with 358 additions and 29 deletions
+52 -15
View File
@@ -420,10 +420,13 @@ def projects():
err = _check_perm_point('project.view')
if err:
return err
status_f = (request.args.get('status') or '').strip()
rows = db.q('SELECT * FROM projects ORDER BY id DESC')
visible = _visible_project_ids()
if visible is not None:
rows = [r for r in rows if r['id'] in visible]
if status_f and status_f != 'all':
rows = [r for r in rows if r['status'] == status_f]
for r in rows:
r['task_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0', (r['id'],))[0]['c']
r['done_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
@@ -1025,21 +1028,36 @@ def scope_args_ph(ids):
@require_auth
@require_perm('log.view')
def logs():
limit = min(int(request.args.get('limit', 100)), 500)
"""运行日志:分页 + 级别/关键词筛选"""
page = max(int(request.args.get('page', 1)), 1)
page_size = min(int(request.args.get('page_size', 50)), 200)
level = (request.args.get('level') or '').strip()
q = (request.args.get('q') or '').strip()
visible = _visible_project_ids()
where, args = [], []
if level:
where.append('l.level=?')
args.append(level)
if q:
where.append('(l.message LIKE ? OR t.title LIKE ?)')
args += [f'%{q}%', f'%{q}%']
if visible is None:
rows = db.q(
'SELECT l.*, t.title AS task_title FROM task_logs l '
'LEFT JOIN tasks t ON t.id=l.task_id ORDER BY l.id DESC LIMIT ?', (limit,))
pass
elif not visible:
return jsonify({'ok': True, 'data': [], 'total': 0, 'page': page, 'page_size': page_size})
else:
if not visible:
return jsonify({'ok': True, 'data': []})
rows = db.q(
f'SELECT l.*, t.title AS task_title FROM task_logs l '
f'LEFT JOIN tasks t ON t.id=l.task_id '
f'WHERE (t.project_id IN ({scope_args_ph(visible)}) OR t.id IS NULL) '
f'ORDER BY l.id DESC LIMIT ?', (*visible, limit))
return jsonify({'ok': True, 'data': rows})
where.append(f'(t.project_id IN ({scope_args_ph(visible)}) OR t.id IS NULL)')
args += list(visible)
where_sql = (' WHERE ' + ' AND '.join(where)) if where else ''
total = db.q(
f'SELECT COUNT(*) c FROM task_logs l LEFT JOIN tasks t ON t.id=l.task_id{where_sql}',
args)[0]['c']
rows = db.q(
f'SELECT l.*, t.title AS task_title FROM task_logs l '
f'LEFT JOIN tasks t ON t.id=l.task_id{where_sql} '
f'ORDER BY l.id DESC LIMIT ? OFFSET ?',
(*args, page_size, (page - 1) * page_size))
return jsonify({'ok': True, 'data': rows, 'total': total, 'page': page, 'page_size': page_size})
@app.route('/api/projects/<int:pid>/workflow/run', methods=['POST'])
@@ -1654,9 +1672,28 @@ def demo_serve_root(pid):
@require_auth
@require_perm('alert.view')
def alerts():
limit = min(int(request.args.get('limit', 100)), 500)
rows = db.q('SELECT * FROM alerts ORDER BY id DESC LIMIT ?', (limit,))
return jsonify({'ok': True, 'data': rows})
"""告警中心:分页 + 级别/类型/已读筛选"""
page = max(int(request.args.get('page', 1)), 1)
page_size = min(int(request.args.get('page_size', 50)), 200)
level = (request.args.get('level') or '').strip()
typ = (request.args.get('type') or '').strip()
read = (request.args.get('read') or '').strip()
where, args = [], []
if level:
where.append('level=?')
args.append(level)
if typ:
where.append('type=?')
args.append(typ)
if read in ('0', '1'):
where.append('read=?')
args.append(int(read))
where_sql = (' WHERE ' + ' AND '.join(where)) if where else ''
total = db.q(f'SELECT COUNT(*) c FROM alerts{where_sql}', args)[0]['c']
rows = db.q(
f'SELECT * FROM alerts{where_sql} ORDER BY id DESC LIMIT ? OFFSET ?',
(*args, page_size, (page - 1) * page_size))
return jsonify({'ok': True, 'data': rows, 'total': total, 'page': page, 'page_size': page_size})
@app.route('/api/alerts/unread_count')
+259 -14
View File
@@ -17,6 +17,110 @@ const PRI = {high:'高', medium:'中', low:'低'};
const KANBAN_ORDER = ['todo','running','review','done','rejected','failed'];
const ALERT_LEVEL = {info:'️', warn:'⚠️', critical:'🚨'};
/* ---------- 轻量 Markdown 渲染(无外部依赖) ---------- */
function mdInline(s) {
// 先转义,再应用行内格式
s = esc(s);
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
s = s.replace(/__([^_]+)__/g, '<b>$1</b>');
s = s.replace(/\*([^*\n]+)\*/g, '<i>$1</i>');
s = s.replace(/(^|[^\w])\[([^\]]+)\]\(([^)\s]+)\)/g, '$1<a href="$3" target="_blank" rel="noopener">$2</a>');
return s;
}
function mdToHtml(md) {
if (!md) return '';
const lines = String(md).replace(/\r\n?/g, '\n').split('\n');
const out = [];
let i = 0;
const flushCode = (buf, lang) => {
if (buf.length) out.push(`<pre class="md-code"><code>${esc(buf.join('\n'))}</code></pre>`);
};
let codeBuf = null, codeLang = '';
while (i < lines.length) {
let line = lines[i];
const fence = line.match(/^```(\w*)\s*$/);
if (fence) {
if (codeBuf !== null) { flushCode(codeBuf, codeLang); codeBuf = null; }
else { codeBuf = []; codeLang = fence[1]; }
i++; continue;
}
if (codeBuf !== null) { codeBuf.push(line); i++; continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const lv = h[1].length; out.push(`<h${lv} class="md-h">${mdInline(h[2])}</h${lv}>`); i++; continue; }
if (/^\s*([-*+])\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*([-*+])\s+/.test(lines[i])) {
items.push(`<li>${mdInline(lines[i].replace(/^\s*([-*+])\s+/, ''))}</li>`); i++;
}
out.push(`<ul class="md-ul">${items.join('')}</ul>`); continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(`<li>${mdInline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`); i++;
}
out.push(`<ol class="md-ol">${items.join('')}</ol>`); continue;
}
if (/^\s*>\s?/.test(line)) {
const items = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
items.push(mdInline(lines[i].replace(/^\s*>\s?/, ''))); i++;
}
out.push(`<blockquote class="md-quote">${items.join('<br>')}</blockquote>`); continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { out.push('<hr class="md-hr">'); i++; continue; }
if (/^\s*$/.test(line)) { i++; continue; }
// 表格:| a | b | 且下一行是 |---| 分隔
if (line.trim().startsWith('|') && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
const head = line.trim().replace(/^\||\|$/g, '').split('|').map(x => x.trim());
i += 2;
const body = [];
while (i < lines.length && lines[i].trim().startsWith('|')) {
body.push('<tr>' + lines[i].trim().replace(/^\||\|$/g, '').split('|').map(x => `<td>${mdInline(x.trim())}</td>`).join('') + '</tr>');
i++;
}
out.push(`<table class="md-table"><thead><tr>${head.map(x => `<th>${mdInline(x)}</th>`).join('')}</tr></thead><tbody>${body.join('')}</tbody></table>`);
continue;
}
// 普通段落:连续行合并
const para = [line];
i++;
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6})\s/.test(lines[i])
&& !/^```/.test(lines[i]) && !/^\s*([-*+]|\d+\.)\s+/.test(lines[i])
&& !/^\s*>\s?/.test(lines[i]) && !/^\s*([-*_])\1{2,}\s*$/.test(lines[i])
&& !lines[i].trim().startsWith('|')) {
para.push(lines[i]); i++;
}
out.push(`<p class="md-p">${para.map(l => mdInline(l)).join('<br>')}</p>`);
}
if (codeBuf !== null) flushCode(codeBuf, codeLang);
return out.join('\n');
}
/* ---------- Markdown 区块(带 原文/Markdown 切换,默认 Markdown ---------- */
const mdRaw = {}; // id -> {md: bool, text: string}
function mdToggleBtn(id) {
// 切换开关:放入区块标题行右侧(由调用方放到 .sec-title 内)
return `<span class="md-toggle"><button id="md-btn-${id}" class="on" onclick="mdToggle('${id}')">📝 Markdown</button></span>`;
}
function mdBody(id, text, cls = '') {
mdRaw[id] = {md: true, text: text || ''};
return `<div class="md-box ${cls}" id="md-body-${id}">${mdToHtml(text)}</div>`;
}
function mdToggle(id) {
const rec = mdRaw[id];
if (!rec) return;
rec.md = !rec.md;
const body = $('#md-body-' + id), btn = $('#md-btn-' + id);
if (!body || !btn) return;
body.innerHTML = rec.md ? mdToHtml(rec.text) : `<pre class="md-plain">${esc(rec.text)}</pre>`;
btn.textContent = rec.md ? '📝 Markdown' : '📄 原文';
btn.classList.toggle('on', rec.md);
}
/* ---------- API ---------- */
async function api(path, opts = {}) {
const res = await fetch(path, {
@@ -156,16 +260,36 @@ async function pageDashboard() {
}
/* ---------- 项目列表 ---------- */
let projFilter = 'all';
const PROJ_STATUS = {planning:'规划中', active:'进行中', review:'待验收', done:'已完成', archived:'已归档'};
let projNameMap = {}; // id -> name(删除确认用)
async function pageProjects() {
const list = (await api('/api/projects')).data;
projNameMap = {}; list.forEach(p => { projNameMap[p.id] = p.name; });
const shown = projFilter === 'all' ? list : list.filter(p => p.status === projFilter);
const statusCls = s => s === 'active' ? 'running' : s === 'review' ? 'review' : s === 'done' ? 'done' : s === 'archived' ? 'cancelled' : 'todo';
$('#main').innerHTML = `
<h1 class="page-title">项目<small>立项 → 规划 → 执行 → 验收 → 归档</small></h1>
<div class="toolbar"><button class="btn primary" onclick="openProjectModal()"> 新建项目</button></div>
<div class="filter-bar">
<div class="fgroup"><span class="flbl">状态</span>
<button class="fbtn ${projFilter === 'all' ? 'on' : ''}" onclick="projFilter='all';pageProjects()">全部 ${list.length}</button>
${Object.entries(PROJ_STATUS).map(([k, v]) => `<button class="fbtn ${projFilter === k ? 'on' : ''}" onclick="projFilter='${k}';pageProjects()">${v} ${list.filter(p => p.status === k).length}</button>`).join('')}
</div>
</div>
<div class="grid g3" id="proj-list">
${list.map(p => `
${shown.map(p => `
<div class="card proj-card" onclick="location.hash='#/project/${p.id}'">
<div style="display:flex;justify-content:space-between;align-items:center">
<b>${esc(p.name)}</b><span class="badge st-${p.status === 'active' ? 'running' : p.status === 'review' ? 'review' : p.status === 'done' ? 'done' : p.status === 'archived' ? 'cancelled' : 'todo'}">${{active:'进行中', review:'待验收', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}</span>
<b>${esc(p.name)}</b>
<span style="display:flex;align-items:center;gap:6px" onclick="event.stopPropagation()">
${p.perm === 'view' ? `<span class="badge st-${statusCls(p.status)}">${PROJ_STATUS[p.status] || p.status}</span>` : `
<select class="pstatus-sel" data-pid="${p.id}" onchange="changeProjStatus(${p.id}, this.value)" title="更改项目状态">
${Object.entries(PROJ_STATUS).map(([k, v]) => `<option value="${k}" ${p.status === k ? 'selected' : ''}>${v}</option>`).join('')}
</select>`}
${p.perm !== 'view' ? `<button class="btn sm danger" onclick="delProject(${p.id})" title="删除项目">🗑</button>` : ''}
</span>
</div>
<div style="color:var(--muted);font-size:12px;margin-top:8px;min-height:32px">${esc(p.objective || p.description || '暂无描述')}</div>
<div style="margin-top:10px;font-size:12px;color:var(--muted)">任务 ${p.task_count} · 完成 ${p.done_count}
@@ -174,10 +298,28 @@ async function pageProjects() {
${p.perm ? `<span class="tag" style="float:right">${{view:'查看',manage:'管理',admin:'管理员'}[p.perm] || p.perm}</span>` : ''}
${p.demo_url ? `<a href="${esc(p.demo_url)}" target="_blank" style="color:var(--accent);float:right;margin-right:6px" onclick="event.stopPropagation()">🌐 Demo</a>` : ''}
</div>
</div>`).join('') || '<div class="empty">还没有项目,点右上角新建</div>'}
</div>`).join('') || '<div class="empty">' + (projFilter === 'all' ? '还没有项目,点右上角新建' : '该状态下暂无项目') + '</div>'}
</div>`;
}
async function changeProjStatus(pid, status) {
try {
await api(`/api/projects/${pid}`, {method: 'PUT', body: {status}});
toast(`✅ 项目状态已改为「${PROJ_STATUS[status]}`, 'ok');
pageProjects();
} catch (e) { toast(e.message, 'err'); pageProjects(); }
}
async function delProject(pid) {
const name = projNameMap[pid] || ('#' + pid);
if (!confirm(`确认删除项目「${name}」?\n将同时删除其全部任务、日志、交付记录与工作目录文件,且不可恢复!`)) return;
try {
await api(`/api/projects/${pid}`, {method: 'DELETE'});
toast('🗑 项目已删除', 'ok');
pageProjects();
} catch (e) { toast(e.message, 'err'); }
}
function openProjectModal(p = {}) {
const usersP = api('/api/users');
const workersP = api('/api/workers');
@@ -1297,10 +1439,10 @@ async function openTaskDetail(tid) {
<dt>创建/更新</dt><dd class="mono">${fmtTime(t.created_at)} / ${fmtTime(t.updated_at)}</dd>
<dt>截止</dt><dd>${t.deadline || '—'}</dd>
</div>
<div class="sec-title" style="margin-top:12px">任务指令</div>
<div class="output-box" style="max-height:140px">${esc(t.description || t.title)}</div>
${t.output_text ? `<div class="sec-title">产出物 v${t.output_version}${t.output_text.length} 字)</div>
<div class="output-box">${esc(t.output_text)}</div>` : ''}
<div class="sec-title md-title" style="margin-top:12px"><span>任务指令</span>${mdToggleBtn('td-desc-' + t.id)}</div>
${mdBody('td-desc-' + t.id, t.description || t.title, 'sm')}
${t.output_text ? `<div class="sec-title md-title"><span>产出物 v${t.output_version}${t.output_text.length} 字)</span>${mdToggleBtn('td-out-' + t.id)}</div>
${mdBody('td-out-' + t.id, t.output_text)}` : ''}
${t.error ? `<div class="sec-title" style="color:var(--danger)">错误信息</div>
<div class="output-box" style="border-color:var(--danger)">${esc(t.error)}</div>` : ''}
<div class="sec-title">执行日志</div>
@@ -1699,26 +1841,81 @@ async function pageReports() {
}
/* ---------- 运行日志 ---------- */
let logsState = {page: 1, pageSize: 50, level: '', q: ''};
window.logsState = logsState; // 供分页组件通过 window[stateVar] 读写
async function pageLogs() {
const logs = (await api('/api/logs?limit=200')).data;
const st = logsState;
const qs = new URLSearchParams({page: st.page, page_size: st.pageSize, level: st.level});
if (st.q) qs.set('q', st.q);
const r = await api(`/api/logs?${qs}`);
const totalPages = Math.max(1, Math.ceil(r.total / st.pageSize));
const levels = ['info', 'success', 'warn', 'error'];
const lvLabel = {info:'️ 信息', success:'✅ 成功', warn:'⚠️ 警告', error:'❌ 错误'};
$('#main').innerHTML = `
<h1 class="page-title">运行日志<small>全链路可追溯,每个结论都能溯源</small></h1>
<div class="card">${logs.map(l => `<div class="log-line ${l.level}"><span class="ts">${fmtTime(l.created_at)}</span>
[任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}</div>`).join('') || '<div class="empty">暂无日志</div>'}</div>`;
<div class="filter-bar">
<div class="fgroup"><span class="flbl">级别</span>
<button class="fbtn ${st.level === '' ? 'on' : ''}" onclick="logsState.level='';logsState.page=1;pageLogs()">全部</button>
${levels.map(l => `<button class="fbtn ${st.level === l ? (l === 'warn' ? 'on warn-on' : l === 'error' ? 'on danger-on' : 'on') : ''}" onclick="logsState.level='${l}';logsState.page=1;pageLogs()">${lvLabel[l]}</button>`).join('')}
</div>
<div class="fgroup"><span class="flbl">🔍 关键词</span>
<input id="logs-q" placeholder="搜索消息/任务标题…" value="${esc(st.q)}" style="background:var(--panel);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 10px;font-size:12px;width:200px">
<button class="fbtn on" onclick="logsState.q=$('#logs-q').value.trim();logsState.page=1;pageLogs()">搜索</button>
${st.q ? `<button class="fbtn" onclick="logsState.q='';logsState.page=1;pageLogs()">✕ 清除</button>` : ''}
</div>
</div>
${pagerHtml(r.page, totalPages, r.total, 'logsState', 'pageLogs', 'top')}
<div class="card">${r.data.map(l => `<div class="log-line ${l.level}"><span class="ts">${fmtTime(l.created_at)}</span>
[任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}</div>`).join('') || '<div class="empty">暂无日志</div>'}</div>
${pagerHtml(r.page, totalPages, r.total, 'logsState', 'pageLogs', 'bottom')}`;
bindPagerGo('logsState', totalPages, 'pageLogs');
const qel = $('#logs-q');
if (qel) qel.addEventListener('keydown', e => { if (e.key === 'Enter') { logsState.q = qel.value.trim(); logsState.page = 1; pageLogs(); } });
}
/* ---------- 告警中心 ---------- */
let alertsState = {page: 1, pageSize: 20, level: '', type: '', read: ''};
window.alertsState = alertsState; // 供分页组件通过 window[stateVar] 读写
async function pageAlerts() {
const alerts = (await api('/api/alerts?limit=200')).data;
const st = alertsState;
const qs = new URLSearchParams({page: st.page, page_size: st.pageSize});
if (st.level) qs.set('level', st.level);
if (st.type) qs.set('type', st.type);
if (st.read !== '') qs.set('read', st.read);
const r = await api(`/api/alerts?${qs}`);
const totalPages = Math.max(1, Math.ceil(r.total / st.pageSize));
const levels = ['info', 'warn', 'critical'];
const lvLabel = {info:'️', warn:'⚠️', critical:'🚨'};
$('#main').innerHTML = `
<h1 class="page-title">告警中心<small>任务失败 · 预算超标 · Worker 异常 · 成本拦截</small></h1>
<div class="toolbar"><button class="btn" onclick="readAllAlerts()">✔ 全部标记已读</button></div>
<div class="card">${alerts.map(a => `
<div class="filter-bar">
<div class="fgroup"><span class="flbl">级别</span>
<button class="fbtn ${st.level === '' ? 'on' : ''}" onclick="alertsState.level='';alertsState.page=1;pageAlerts()">全部</button>
${levels.map(l => `<button class="fbtn ${st.level === l ? (l === 'critical' ? 'on danger-on' : 'on') : ''}" onclick="alertsState.level='${l}';alertsState.page=1;pageAlerts()">${lvLabel[l]} ${l}</button>`).join('')}
</div>
<div class="fgroup"><span class="flbl">类型</span>
<button class="fbtn ${st.type === '' ? 'on' : ''}" onclick="alertsState.type='';alertsState.page=1;pageAlerts()">全部</button>
${['task_failed','task_review','reject','budget','blocker','eval_done'].map(t => `<button class="fbtn ${st.type === t ? 'on' : ''}" onclick="alertsState.type='${t}';alertsState.page=1;pageAlerts()">${{task_failed:'任务失败', task_review:'待审核', reject:'打回', budget:'预算', blocker:'难关', eval_done:'评估完成'}[t] || t}</button>`).join('')}
</div>
<div class="fgroup"><span class="flbl">状态</span>
<button class="fbtn ${st.read === '' ? 'on' : ''}" onclick="alertsState.read='';alertsState.page=1;pageAlerts()">全部</button>
<button class="fbtn ${st.read === '0' ? 'on warn-on' : ''}" onclick="alertsState.read='0';alertsState.page=1;pageAlerts()">未读</button>
<button class="fbtn ${st.read === '1' ? 'on' : ''}" onclick="alertsState.read='1';alertsState.page=1;pageAlerts()">已读</button>
</div>
<div style="flex:1"></div>
<button class="btn" onclick="readAllAlerts()">✔ 全部标记已读</button>
</div>
${pagerHtml(r.page, totalPages, r.total, 'alertsState', 'pageAlerts', 'top')}
<div class="card">${r.data.map(a => `
<div class="alert-row ${a.read ? '' : 'unread'} ${a.level === 'critical' ? 'critical' : ''}" onclick="readAlert(${a.id})">
<div class="t">${ALERT_LEVEL[a.level] || '️'} ${esc(a.title)} ${a.read ? '' : '<span class="badge st-review">未读</span>'}</div>
<div class="d">${esc(a.detail)}</div>
<div class="tm">${fmtTime(a.created_at)} · ${esc(a.type)}</div>
</div>`).join('') || '<div class="empty">暂无告警 🎉</div>'}</div>`;
</div>`).join('') || '<div class="empty">暂无告警 🎉</div>'}</div>
${pagerHtml(r.page, totalPages, r.total, 'alertsState', 'pageAlerts', 'bottom')}`;
bindPagerGo('alertsState', totalPages, 'pageAlerts');
refreshAlertBadge();
}
async function readAlert(id) {
@@ -1730,6 +1927,54 @@ async function readAllAlerts() {
toast('已全部标记为已读', 'ok'); pageAlerts();
}
/* ---------- 分页组件(快速定位页数,顶部/底部各一份 + 每页条数) ---------- */
function pagerHtml(page, totalPages, total, stateVar, refreshFn, pos = 'bottom') {
const st = window[stateVar] || {};
const sizes = [10, 20, 50, 100];
const sizeSel = `<select class="psize-sel" onchange="setPageSize('${stateVar}', this.value, '${refreshFn}')" title="每页条数">
${sizes.map(s => `<option value="${s}" ${st.pageSize === s ? 'selected' : ''}>${s} 条/页</option>`).join('')}
</select>`;
if (totalPages <= 1) return total ? `<div class="pager ${pos}"><span class="pg-info">共 ${total} 条</span>${sizeSel}</div>` : '';
const pages = [];
const add = p => { if (pages[pages.length - 1] !== p && p >= 1 && p <= totalPages) pages.push(p); };
add(1);
const lo = Math.max(2, page - 2), hi = Math.min(totalPages - 1, page + 2);
if (lo > 2) pages.push('…');
for (let p = lo; p <= hi; p++) add(p);
if (hi < totalPages - 1) pages.push('…');
add(totalPages);
return `<div class="pager ${pos}">
${sizeSel}
<span class="pg-info">共 ${total} 条 · ${page}/${totalPages} 页</span>
<button class="pbtn" ${page <= 1 ? 'disabled' : ''} onclick="${stateVar}.page=${page - 1};${refreshFn}()"></button>
${pages.map(p => p === '…' ? '<span class="pgap">…</span>' :
`<button class="pbtn ${p === page ? 'cur' : ''}" onclick="${stateVar}.page=${p};${refreshFn}()">${p}</button>`).join('')}
<button class="pbtn" ${page >= totalPages ? 'disabled' : ''} onclick="${stateVar}.page=${page + 1};${refreshFn}()"></button>
<span class="pgo">跳至 <input id="pager-go-${pos}" type="number" min="1" max="${totalPages}" value="${page}"> 页 <button class="pbtn" onclick="goPageInput('${stateVar}', ${totalPages}, '${refreshFn}', '${pos}')">GO</button></span>
</div>`;
}
function setPageSize(stateVar, size, refreshFn) {
window[stateVar].pageSize = parseInt(size) || 50;
window[stateVar].page = 1;
window[refreshFn]();
}
function goPageInput(stateVar, totalPages, refreshFn, pos) {
const el = document.getElementById('pager-go-' + pos);
const v = parseInt(el?.value || '');
if (!v || v < 1 || v > totalPages) return toast(`页码需在 1-${totalPages} 之间`, 'err');
window[stateVar].page = v;
window[refreshFn]();
}
function bindPagerGo(stateVar, totalPages, refreshFn) {
['top', 'bottom'].forEach(pos => {
const el = document.getElementById('pager-go-' + pos);
if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') goPageInput(stateVar, totalPages, refreshFn, pos); });
});
}
/* ---------- 开放 API ---------- */
async function pageApiTokens() {
const tokens = (await api('/api/tokens')).data;
+47
View File
@@ -126,6 +126,53 @@ tr:hover td{background:var(--panel2)}
.proj-card:hover{border-color:var(--accent)}
.pager-tip{color:var(--muted);font-size:12px}
.pill{display:inline-block;background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:12px;margin:2px}
.pstatus-sel{background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:2px 6px;font-size:12px;cursor:pointer}
.pstatus-sel:hover{border-color:var(--accent)}
/* 分页 + 筛选 */
.filter-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px}
.filter-bar .fgroup{display:flex;gap:4px;align-items:center;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:4px}
.filter-bar .fgroup .flbl{color:var(--muted);font-size:12px;padding:0 8px 0 6px}
.filter-bar .fbtn{border:1px solid transparent;background:transparent;color:var(--muted);padding:4px 10px;border-radius:6px;font-size:12px;cursor:pointer}
.filter-bar .fbtn:hover{color:var(--accent)}
.filter-bar .fbtn.on{background:var(--accent);color:#fff}
.filter-bar .fbtn.on.warn-on{background:var(--warn)}
.filter-bar .fbtn.on.danger-on{background:var(--danger)}
.pager{display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-top:14px;justify-content:center}
.pager.top{margin-top:0;margin-bottom:14px}
.psize-sel{background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:4px 6px;font-size:12px;cursor:pointer;height:30px}
.psize-sel:hover{border-color:var(--accent)}
.pager .pbtn{min-width:30px;height:30px;padding:0 8px;border:1px solid var(--border);background:var(--panel2);color:var(--text);border-radius:7px;font-size:12px;cursor:pointer}
.pager .pbtn:hover{border-color:var(--accent);color:var(--accent)}
.pager .pbtn.cur{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:700}
.pager .pbtn:disabled{opacity:.35;cursor:not-allowed}
.pager .pgap{color:var(--muted);font-size:12px;padding:0 2px}
.pager .pgo{display:flex;gap:6px;align-items:center;margin-left:6px}
.pager .pgo input{width:52px;height:28px;background:var(--panel);border:1px solid var(--border);color:var(--text);border-radius:7px;text-align:center;font-size:12px}
.pager .pg-info{color:var(--muted);font-size:12px;margin-right:8px}
/* Markdown 渲染 */
.md-box{max-height:320px;overflow-y:auto;line-height:1.75;font-size:13px}
.md-box.sm{max-height:160px}
.md-plain{white-space:pre-wrap;word-break:break-word;margin:0}
.md-h{margin:14px 0 8px;font-weight:700;line-height:1.4}
.md-h h1,.md-h h2,.md-h h3,.md-h h4,.md-h h5,.md-h h6{margin:14px 0 8px}
.md-p{margin:0 0 8px}
.md-ul,.md-ol{margin:0 0 8px;padding-left:22px}
.md-ul li,.md-ol li{margin:3px 0}
.md-quote{border-left:3px solid var(--accent);background:var(--panel2);padding:8px 12px;margin:0 0 10px;border-radius:0 8px 8px 0;color:var(--muted)}
.md-hr{border:none;border-top:1px solid var(--border);margin:12px 0}
.md-table{border-collapse:collapse;margin:8px 0;font-size:12px;width:100%}
.md-table th,.md-table td{border:1px solid var(--border);padding:6px 10px;text-align:left}
.md-table th{background:var(--panel2)}
.md-code{background:#0b0f18;border:1px solid var(--border);border-radius:8px;padding:10px 12px;overflow-x:auto;font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.6;margin:8px 0}
.md-box code{background:var(--panel2);border:1px solid var(--border);border-radius:4px;padding:1px 5px;font-family:ui-monospace,Consolas,monospace;font-size:12px}
.md-box pre.md-code code{background:none;border:none;padding:0}
.md-box a{color:var(--accent)}
.md-toggle{font-size:11px;white-space:nowrap}
.md-toggle button{border:1px solid var(--border);background:var(--panel2);color:var(--muted);padding:2px 8px;cursor:pointer;font-size:11px;border-radius:5px}
.md-toggle button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.md-title{display:flex;align-items:center;justify-content:space-between;gap:10px}
/* V1tabs / DAG / 告警 / 开关 */
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}