V3.3 功能: 日志/告警分页筛选 + 项目状态筛选/删除/改状态 + 任务详情 Markdown
- 运行日志: 分页(快速跳页/上一页/下一页) + 级别筛选(全部/信息/成功/警告/错误) + 关键词搜索 - 告警中心: 分页 + 级别/类型/已读未读筛选 - 项目列表: 状态筛选(全部/规划中/进行中/待验收/已完成/已归档, 带计数, 默认全部) + 卡片内状态下拉直接改状态 + 删除按钮(带确认, 权限管理级) - 任务详情弹窗: 任务指令/产出物按 Markdown 渲染(内置轻量渲染器, 无外部依赖, 支持标题/加粗/斜体/代码块/列表/引用/表格/链接), 可一键切换原文视图, 默认 Markdown - 后端: /api/logs /api/alerts 支持 page/page_size/level/type/read/q 参数并返回 total; /api/projects 支持 status 筛选
This commit is contained in:
@@ -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')
|
||||
|
||||
+236
-12
@@ -17,6 +17,107 @@ 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 mdBlock(id, text, cls = '') {
|
||||
mdRaw[id] = {md: true, text: text || ''};
|
||||
return `<div class="md-toggle"><button id="md-btn-${id}" class="on" onclick="mdToggle('${id}')">📝 Markdown</button></div>
|
||||
<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 +257,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 +295,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');
|
||||
@@ -1298,9 +1437,9 @@ async function openTaskDetail(tid) {
|
||||
<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>
|
||||
${mdBlock('td-desc-' + t.id, t.description || t.title, 'sm')}
|
||||
${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>` : ''}
|
||||
${mdBlock('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 +1838,77 @@ async function pageReports() {
|
||||
}
|
||||
|
||||
/* ---------- 运行日志 ---------- */
|
||||
let logsState = {page: 1, pageSize: 50, level: '', q: ''};
|
||||
|
||||
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>
|
||||
<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')}`;
|
||||
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: ''};
|
||||
|
||||
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>
|
||||
<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')}`;
|
||||
bindPagerGo('alertsState', totalPages, 'pageAlerts');
|
||||
refreshAlertBadge();
|
||||
}
|
||||
async function readAlert(id) {
|
||||
@@ -1730,6 +1920,40 @@ async function readAllAlerts() {
|
||||
toast('已全部标记为已读', 'ok'); pageAlerts();
|
||||
}
|
||||
|
||||
/* ---------- 分页组件(快速定位页数) ---------- */
|
||||
function pagerHtml(page, totalPages, total, stateVar, refreshFn) {
|
||||
if (totalPages <= 1) return total ? `<div class="pager"><span class="pg-info">共 ${total} 条</span></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">
|
||||
<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" type="number" min="1" max="${totalPages}" value="${page}"> 页 <button class="pbtn" onclick="goPageInput('${stateVar}', ${totalPages}, '${refreshFn}')">GO</button></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function goPageInput(stateVar, totalPages, refreshFn) {
|
||||
const v = parseInt($('#pager-go')?.value || '');
|
||||
if (!v || v < 1 || v > totalPages) return toast(`页码需在 1-${totalPages} 之间`, 'err');
|
||||
window[stateVar].page = v;
|
||||
window[refreshFn]();
|
||||
}
|
||||
|
||||
function bindPagerGo(stateVar, totalPages, refreshFn) {
|
||||
const el = $('#pager-go');
|
||||
if (!el) return;
|
||||
el.addEventListener('keydown', e => { if (e.key === 'Enter') goPageInput(stateVar, totalPages, refreshFn); });
|
||||
}
|
||||
|
||||
/* ---------- 开放 API ---------- */
|
||||
async function pageApiTokens() {
|
||||
const tokens = (await api('/api/tokens')).data;
|
||||
|
||||
@@ -126,6 +126,49 @@ 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 .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;float:right}
|
||||
.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)}
|
||||
|
||||
/* V1:tabs / DAG / 告警 / 开关 */
|
||||
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
|
||||
|
||||
Reference in New Issue
Block a user