@@ -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 ;