diff --git a/app.py b/app.py index 8de964e..760cb54 100644 --- a/app.py +++ b/app.py @@ -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//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') diff --git a/static/app.js b/static/app.js index e37a8cf..3cf208e 100644 --- a/static/app.js +++ b/static/app.js @@ -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, '$1'); + s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); + s = s.replace(/__([^_]+)__/g, '$1'); + s = s.replace(/\*([^*\n]+)\*/g, '$1'); + s = s.replace(/(^|[^\w])\[([^\]]+)\]\(([^)\s]+)\)/g, '$1$2'); + 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(`
${esc(buf.join('\n'))}
`); + }; + 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(`${mdInline(h[2])}`); i++; 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(``); continue; + } + if (/^\s*\d+\.\s+/.test(line)) { + const items = []; + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { + items.push(`
  • ${mdInline(lines[i].replace(/^\s*\d+\.\s+/, ''))}
  • `); i++; + } + out.push(`
      ${items.join('')}
    `); 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(`
    ${items.join('
    ')}
    `); continue; + } + if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { out.push('
    '); 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('' + lines[i].trim().replace(/^\||\|$/g, '').split('|').map(x => `${mdInline(x.trim())}`).join('') + ''); + i++; + } + out.push(`${head.map(x => ``).join('')}${body.join('')}
    ${mdInline(x)}
    `); + 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(`

    ${para.map(l => mdInline(l)).join('
    ')}

    `); + } + 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 `
    +
    ${mdToHtml(text)}
    `; +} +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) : `
    ${esc(rec.text)}
    `; + 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 = `

    项目立项 → 规划 → 执行 → 验收 → 归档

    +
    +
    状态 + + ${Object.entries(PROJ_STATUS).map(([k, v]) => ``).join('')} +
    +
    - ${list.map(p => ` + ${shown.map(p => `
    - ${esc(p.name)}${{active:'进行中', review:'待验收', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]} + ${esc(p.name)} + + ${p.perm === 'view' ? `${PROJ_STATUS[p.status] || p.status}` : ` + `} + ${p.perm !== 'view' ? `` : ''} +
    ${esc(p.objective || p.description || '暂无描述')}
    任务 ${p.task_count} · 完成 ${p.done_count} @@ -174,10 +295,28 @@ async function pageProjects() { ${p.perm ? `${{view:'查看',manage:'管理',admin:'管理员'}[p.perm] || p.perm}` : ''} ${p.demo_url ? `🌐 Demo` : ''}
    -
    `).join('') || '
    还没有项目,点右上角新建
    '} +
    `).join('') || '
    ' + (projFilter === 'all' ? '还没有项目,点右上角新建' : '该状态下暂无项目') + '
    '} `; } +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) {
    截止
    ${t.deadline || '—'}
    任务指令
    -
    ${esc(t.description || t.title)}
    + ${mdBlock('td-desc-' + t.id, t.description || t.title, 'sm')} ${t.output_text ? `
    产出物 v${t.output_version}(${t.output_text.length} 字)
    -
    ${esc(t.output_text)}
    ` : ''} + ${mdBlock('td-out-' + t.id, t.output_text)}` : ''} ${t.error ? `
    错误信息
    ${esc(t.error)}
    ` : ''}
    执行日志
    @@ -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 = `

    运行日志全链路可追溯,每个结论都能溯源

    -
    ${logs.map(l => `
    ${fmtTime(l.created_at)} - [任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}
    `).join('') || '
    暂无日志
    '}
    `; +
    +
    级别 + + ${levels.map(l => ``).join('')} +
    +
    🔍 关键词 + + + ${st.q ? `` : ''} +
    +
    +
    ${r.data.map(l => `
    ${fmtTime(l.created_at)} + [任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}
    `).join('') || '
    暂无日志
    '}
    + ${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 = `

    告警中心任务失败 · 预算超标 · Worker 异常 · 成本拦截

    -
    -
    ${alerts.map(a => ` +
    +
    级别 + + ${levels.map(l => ``).join('')} +
    +
    类型 + + ${['task_failed','task_review','reject','budget','blocker','eval_done'].map(t => ``).join('')} +
    +
    状态 + + + +
    +
    + +
    +
    ${r.data.map(a => `
    ${ALERT_LEVEL[a.level] || 'ℹ️'} ${esc(a.title)} ${a.read ? '' : '未读'}
    ${esc(a.detail)}
    ${fmtTime(a.created_at)} · ${esc(a.type)}
    -
    `).join('') || '
    暂无告警 🎉
    '}
    `; +
    `).join('') || '
    暂无告警 🎉
    '} + ${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 ? `
    共 ${total} 条
    ` : ''; + 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 `
    + 共 ${total} 条 · ${page}/${totalPages} 页 + + ${pages.map(p => p === '…' ? '' : + ``).join('')} + + 跳至 +
    `; +} + +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; diff --git a/static/style.css b/static/style.css index 8790008..d3a55e3 100644 --- a/static/style.css +++ b/static/style.css @@ -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)}